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            post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
657        )
658        .route("/locate-report", post(locate_report_handler))
659        .route("/locate-reports-dir", post(locate_reports_dir_handler))
660        .route("/relocate-scan", post(relocate_scan_handler))
661        .route("/watched-dirs/add", post(add_watched_dir_handler))
662        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
663        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
664        .route("/view-reports", get(history_handler))
665        .route("/compare-scans", get(compare_select_handler))
666        .route("/compare", get(compare_handler))
667        .route("/multi-compare", get(multi_compare_handler))
668        .route("/images/{folder}/{file}", get(image_handler))
669        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
670        .route("/api/metrics/latest", get(api_metrics_latest_handler))
671        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
672        .route("/api/metrics/history", get(api_metrics_history_handler))
673        .route(
674            "/api/metrics/submodules",
675            get(api_metrics_submodules_handler),
676        )
677        .route("/api/ingest", post(api_ingest_handler))
678        .route("/api/project-history", get(project_history_handler))
679        .route("/trend-reports", get(trend_report_handler))
680        .route("/test-metrics", get(test_metrics_handler))
681        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
682        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
683        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
684        .route("/runs/result/{run_id}", get(async_run_result_handler))
685        .route("/embed/summary", get(embed_handler))
686        // ── Git browser ────────────────────────────────────────────────────────
687        .route("/git-browser", get(git_browser::git_browser_handler))
688        .route("/api/git/refs", get(git_browser::api_list_refs))
689        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
690        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
691        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
692        .route("/export/pdf", post(export_pdf_handler))
693        // ── Config export / import ─────────────────────────────────────────────
694        .route("/export-config", get(export_config_handler))
695        .route("/import-config", post(import_config_handler))
696        // ── Scan profiles ──────────────────────────────────────────────────────
697        .route("/api/scan-profiles", get(api_list_scan_profiles))
698        .route("/api/scan-profiles", post(api_save_scan_profile))
699        .route(
700            "/api/scan-profiles/{id}",
701            axum::routing::delete(api_delete_scan_profile),
702        )
703        // ── Integrations (webhooks + Confluence) ──────────────────────────────
704        .route("/integrations", get(integrations::integrations_handler))
705        .route(
706            "/webhook-setup",
707            get(|| async { axum::response::Redirect::permanent("/integrations") }),
708        )
709        .route(
710            "/confluence-setup",
711            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
712        )
713        .route("/api/schedules", get(git_webhook::api_list_schedules))
714        .route("/api/schedules", post(git_webhook::api_create_schedule))
715        .route(
716            "/api/schedules",
717            axum::routing::delete(git_webhook::api_delete_schedule),
718        )
719        .route(
720            "/api/confluence/config",
721            get(confluence::api_get_confluence_config),
722        )
723        .route(
724            "/api/confluence/config",
725            post(confluence::api_save_confluence_config),
726        )
727        .route(
728            "/api/confluence/test",
729            post(confluence::api_test_confluence),
730        )
731        .route(
732            "/api/confluence/post",
733            post(confluence::api_post_to_confluence),
734        )
735        .route(
736            "/api/confluence/wiki-markup",
737            get(confluence::api_wiki_markup),
738        )
739        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
740        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
741        .route(
742            "/api/runs/{run_id}",
743            axum::routing::delete(delete_run_handler),
744        )
745        .route("/api/runs/cleanup", post(cleanup_runs_handler))
746        // ── Auto-cleanup policy ────────────────────────────────────────────────
747        .route(
748            "/api/cleanup-policy",
749            get(api_get_cleanup_policy)
750                .post(api_save_cleanup_policy)
751                .delete(api_delete_cleanup_policy),
752        )
753        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
754        // ── REST API reference page ────────────────────────────────────────────
755        .route("/api-docs", get(api_docs_handler))
756        // ── Prometheus metrics — behind API-key auth ───────────────────────────
757        .route("/metrics", get(metrics_handler))
758        .route_layer(middleware::from_fn_with_state(
759            state.clone(),
760            auth::require_api_key,
761        ));
762
763    protected
764        .route("/healthz", get(healthz))
765        .route("/api/health", get(healthz))
766        .route("/api/version", get(api_version_handler))
767        .route("/api/openapi.yaml", get(openapi_yaml_handler))
768        .route("/llms.txt", get(llms_txt_handler))
769        .route("/llms-full.txt", get(llms_full_txt_handler))
770        .route("/badge/{metric}", get(badge_handler))
771        .route("/static/chart.js", get(chart_js_handler))
772        .route("/static/chart-report.js", get(report_chart_js_handler))
773        .route("/auth/login", get(auth::auth_login_get))
774        .route("/auth/login", post(auth::auth_login_post))
775        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
776        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
777        .route(
778            "/webhooks/github",
779            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
780        )
781        .route(
782            "/webhooks/gitlab",
783            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
784        )
785        .route(
786            "/webhooks/bitbucket",
787            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
788        )
789        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
790        .layer(middleware::from_fn_with_state(
791            state.clone(),
792            add_security_headers,
793        ))
794        .layer(build_cors_layer(state.server_mode))
795        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
796        .with_state(state)
797}
798
799/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
800pub fn make_test_router() -> Router {
801    // Suppress native OS dialogs (file pickers, open-path) during tests.
802    std::env::set_var("SLOC_HEADLESS", "1");
803    let tmp = std::env::temp_dir().join("sloc_test");
804    let state = AppState {
805        base_config: AppConfig::default(),
806        artifacts: Arc::new(Mutex::new(HashMap::new())),
807        async_runs: Arc::new(Mutex::new(HashMap::new())),
808        registry: Arc::new(Mutex::new(ScanRegistry::default())),
809        registry_path: tmp.join("registry.json"),
810        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
811        server_mode: false,
812        tls_enabled: false,
813        api_keys: Arc::new(vec![]),
814        rate_limiter: Arc::new(IpRateLimiter::new(
815            Duration::from_mins(1),
816            600,
817            10,
818            Duration::from_hours(1),
819        )),
820        trust_proxy: false,
821        trusted_proxy_ips: vec![],
822        git_clones_dir: tmp.join("git-clones"),
823        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
824        schedules_path: tmp.join("schedules.json"),
825        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
826        scan_profiles_path: tmp.join("scan_profiles.json"),
827        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
828        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
829        confluence_path: tmp.join("confluence_config.json"),
830        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
831        watched_dirs_path: tmp.join("watched_dirs.json"),
832        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
833        cleanup_policy_path: tmp.join("cleanup_policy.json"),
834        cleanup_task_handle: Arc::new(Mutex::new(None)),
835    };
836    build_router(state)
837}
838
839/// Test router with one API key pre-loaded. Used by auth integration tests.
840pub fn make_test_router_with_key(api_key: &str) -> Router {
841    let tmp = std::env::temp_dir().join("sloc_test_key");
842    let state = AppState {
843        base_config: AppConfig::default(),
844        artifacts: Arc::new(Mutex::new(HashMap::new())),
845        async_runs: Arc::new(Mutex::new(HashMap::new())),
846        registry: Arc::new(Mutex::new(ScanRegistry::default())),
847        registry_path: tmp.join("registry.json"),
848        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
849        server_mode: false,
850        tls_enabled: false,
851        api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
852        rate_limiter: Arc::new(IpRateLimiter::new(
853            Duration::from_mins(1),
854            600,
855            10,
856            Duration::from_hours(1),
857        )),
858        trust_proxy: false,
859        trusted_proxy_ips: vec![],
860        git_clones_dir: tmp.join("git-clones"),
861        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
862        schedules_path: tmp.join("schedules.json"),
863        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
864        scan_profiles_path: tmp.join("scan_profiles.json"),
865        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
866        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
867        confluence_path: tmp.join("confluence_config.json"),
868        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
869        watched_dirs_path: tmp.join("watched_dirs.json"),
870        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
871        cleanup_policy_path: tmp.join("cleanup_policy.json"),
872        cleanup_task_handle: Arc::new(Mutex::new(None)),
873    };
874    build_router(state)
875}
876
877/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
878/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
879/// preview restrictions.
880pub fn make_test_router_server_mode() -> Router {
881    std::env::set_var("SLOC_HEADLESS", "1");
882    let tmp = std::env::temp_dir().join("sloc_test_server");
883    let state = AppState {
884        base_config: AppConfig::default(),
885        artifacts: Arc::new(Mutex::new(HashMap::new())),
886        async_runs: Arc::new(Mutex::new(HashMap::new())),
887        registry: Arc::new(Mutex::new(ScanRegistry::default())),
888        registry_path: tmp.join("registry.json"),
889        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
890        server_mode: true,
891        tls_enabled: false,
892        api_keys: Arc::new(vec![]),
893        rate_limiter: Arc::new(IpRateLimiter::new(
894            Duration::from_mins(1),
895            600,
896            10,
897            Duration::from_hours(1),
898        )),
899        trust_proxy: false,
900        trusted_proxy_ips: vec![],
901        git_clones_dir: tmp.join("git-clones"),
902        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
903        schedules_path: tmp.join("schedules.json"),
904        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
905        scan_profiles_path: tmp.join("scan_profiles.json"),
906        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
907        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
908        confluence_path: tmp.join("confluence_config.json"),
909        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
910        watched_dirs_path: tmp.join("watched_dirs.json"),
911        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
912        cleanup_policy_path: tmp.join("cleanup_policy.json"),
913        cleanup_task_handle: Arc::new(Mutex::new(None)),
914    };
915    build_router(state)
916}
917
918/// Test router where the analysis semaphore is pre-exhausted (0 permits).
919/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
920pub fn make_test_router_exhausted_semaphore() -> Router {
921    std::env::set_var("SLOC_HEADLESS", "1");
922    let tmp = std::env::temp_dir().join("sloc_test_exhaust");
923    let sem = Arc::new(tokio::sync::Semaphore::new(0));
924    let state = AppState {
925        base_config: AppConfig::default(),
926        artifacts: Arc::new(Mutex::new(HashMap::new())),
927        async_runs: Arc::new(Mutex::new(HashMap::new())),
928        registry: Arc::new(Mutex::new(ScanRegistry::default())),
929        registry_path: tmp.join("registry.json"),
930        analyze_semaphore: sem,
931        server_mode: false,
932        tls_enabled: false,
933        api_keys: Arc::new(vec![]),
934        rate_limiter: Arc::new(IpRateLimiter::new(
935            Duration::from_mins(1),
936            600,
937            10,
938            Duration::from_hours(1),
939        )),
940        trust_proxy: false,
941        trusted_proxy_ips: vec![],
942        git_clones_dir: tmp.join("git-clones"),
943        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
944        schedules_path: tmp.join("schedules.json"),
945        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
946        scan_profiles_path: tmp.join("scan_profiles.json"),
947        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
948        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
949        confluence_path: tmp.join("confluence_config.json"),
950        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
951        watched_dirs_path: tmp.join("watched_dirs.json"),
952        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
953        cleanup_policy_path: tmp.join("cleanup_policy.json"),
954        cleanup_task_handle: Arc::new(Mutex::new(None)),
955    };
956    build_router(state)
957}
958
959/// Test router with a very tight rate limit (3 req/min). The third request from
960/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
961pub fn make_test_router_tight_rate_limit() -> Router {
962    std::env::set_var("SLOC_HEADLESS", "1");
963    let tmp = std::env::temp_dir().join("sloc_test_rate");
964    let state = AppState {
965        base_config: AppConfig::default(),
966        artifacts: Arc::new(Mutex::new(HashMap::new())),
967        async_runs: Arc::new(Mutex::new(HashMap::new())),
968        registry: Arc::new(Mutex::new(ScanRegistry::default())),
969        registry_path: tmp.join("registry.json"),
970        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
971        server_mode: false,
972        tls_enabled: false,
973        api_keys: Arc::new(vec![]),
974        rate_limiter: Arc::new(IpRateLimiter::new(
975            Duration::from_mins(1),
976            2,
977            5,
978            Duration::from_secs(5),
979        )),
980        trust_proxy: false,
981        trusted_proxy_ips: vec![],
982        git_clones_dir: tmp.join("git-clones"),
983        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
984        schedules_path: tmp.join("schedules.json"),
985        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
986        scan_profiles_path: tmp.join("scan_profiles.json"),
987        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
988        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
989        confluence_path: tmp.join("confluence_config.json"),
990        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
991        watched_dirs_path: tmp.join("watched_dirs.json"),
992        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
993        cleanup_policy_path: tmp.join("cleanup_policy.json"),
994        cleanup_task_handle: Arc::new(Mutex::new(None)),
995    };
996    build_router(state)
997}
998
999/// Test router with a very tight auth lockout (threshold=2, window=200ms).
1000/// Used by tests that need to trigger and verify the auth lockout response.
1001pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1002    std::env::set_var("SLOC_HEADLESS", "1");
1003    let tmp = std::env::temp_dir().join("sloc_test_auth_lockout");
1004    let state = AppState {
1005        base_config: AppConfig::default(),
1006        artifacts: Arc::new(Mutex::new(HashMap::new())),
1007        async_runs: Arc::new(Mutex::new(HashMap::new())),
1008        registry: Arc::new(Mutex::new(ScanRegistry::default())),
1009        registry_path: tmp.join("registry.json"),
1010        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1011        server_mode: false,
1012        tls_enabled: false,
1013        api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
1014        rate_limiter: Arc::new(IpRateLimiter::new(
1015            Duration::from_mins(1),
1016            600,
1017            2,                          // 2 failures triggers lockout
1018            Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
1019        )),
1020        trust_proxy: false,
1021        trusted_proxy_ips: vec![],
1022        git_clones_dir: tmp.join("git-clones"),
1023        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1024        schedules_path: tmp.join("schedules.json"),
1025        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1026        scan_profiles_path: tmp.join("scan_profiles.json"),
1027        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1028        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1029        confluence_path: tmp.join("confluence_config.json"),
1030        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1031        watched_dirs_path: tmp.join("watched_dirs.json"),
1032        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1033        cleanup_policy_path: tmp.join("cleanup_policy.json"),
1034        cleanup_task_handle: Arc::new(Mutex::new(None)),
1035    };
1036    build_router(state)
1037}
1038
1039struct RuntimeSecurityConfig {
1040    api_keys: Vec<secrecy::SecretBox<String>>,
1041    tls_cert: Option<String>,
1042    tls_key: Option<String>,
1043    tls_enabled: bool,
1044    trust_proxy: bool,
1045    trusted_proxy_ips: Vec<IpAddr>,
1046    rate_limiter: Arc<IpRateLimiter>,
1047}
1048
1049fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1050    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1051        .or_else(|_| std::env::var("SLOC_API_KEY"))
1052        .unwrap_or_default()
1053        .split(',')
1054        .map(str::trim)
1055        .filter(|s| !s.is_empty())
1056        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1057        .collect();
1058    if server_mode && api_keys.is_empty() {
1059        println!(
1060            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
1061             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
1062        );
1063    }
1064    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1065    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1066    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1067    if server_mode && !tls_enabled {
1068        println!(
1069            "WARNING: TLS is not configured. Traffic is cleartext. \
1070             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1071             or terminate TLS at a reverse proxy (nginx, caddy)."
1072        );
1073    }
1074    if server_mode {
1075        println!(
1076            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1077             to restrict cross-origin access (comma-separated)."
1078        );
1079    }
1080    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1081    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1082        .unwrap_or_default()
1083        .split(',')
1084        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1085        .collect();
1086    if trust_proxy {
1087        if trusted_proxy_ips.is_empty() {
1088            println!(
1089                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1090                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1091                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1092            );
1093        } else {
1094            println!(
1095                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1096                trusted_proxy_ips
1097                    .iter()
1098                    .map(std::string::ToString::to_string)
1099                    .collect::<Vec<_>>()
1100                    .join(", ")
1101            );
1102        }
1103    } else if server_mode {
1104        println!(
1105            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1106             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1107             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1108             enable per-client rate limiting via X-Forwarded-For."
1109        );
1110    }
1111    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1112        println!(
1113            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1114             DISABLED for all git operations. Remove this variable before production use."
1115        );
1116    }
1117    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1118        .ok()
1119        .and_then(|v| v.parse::<u32>().ok())
1120        .unwrap_or(10);
1121    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1122        .ok()
1123        .and_then(|v| v.parse::<u64>().ok())
1124        .unwrap_or(3600);
1125    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1126    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1127    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1128    let default_rpm: usize = if server_mode { 120 } else { 600 };
1129    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1130        .ok()
1131        .and_then(|v| v.parse::<usize>().ok())
1132        .unwrap_or(default_rpm);
1133    let rate_limiter = Arc::new(IpRateLimiter::new(
1134        Duration::from_mins(1),
1135        rate_limit_rpm,
1136        auth_lockout_threshold,
1137        Duration::from_secs(auth_lockout_secs),
1138    ));
1139    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1140    RuntimeSecurityConfig {
1141        api_keys,
1142        tls_cert,
1143        tls_key,
1144        tls_enabled,
1145        trust_proxy,
1146        trusted_proxy_ips,
1147        rate_limiter,
1148    }
1149}
1150
1151/// # Errors
1152///
1153/// Returns an error if the server fails to bind to the configured address or
1154/// if the TLS configuration cannot be loaded.
1155///
1156/// # Panics
1157///
1158/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1159#[allow(clippy::too_many_lines)]
1160pub async fn serve(config: AppConfig) -> Result<()> {
1161    let bind_address = config.web.bind_address.clone();
1162    let server_mode = config.web.server_mode;
1163    let output_root = resolve_output_root(None);
1164    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1165    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1166        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1167    let mut registry = ScanRegistry::load(&registry_path);
1168    registry.prune_stale();
1169    let _ = registry.save(&registry_path);
1170
1171    let sec = load_runtime_security_config(server_mode);
1172    spawn_upload_staging_cleanup();
1173
1174    let git_clones_dir = resolve_git_clones_dir(&output_root);
1175    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1176        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1177    let schedules = ScheduleStore::load(&schedules_path);
1178    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1179        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1180    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1181    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1182        |_| output_root.join("confluence_config.json"),
1183        PathBuf::from,
1184    );
1185    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1186    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1187        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1188    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1189    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1190        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1191    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1192
1193    let state = AppState {
1194        base_config: config,
1195        artifacts: Arc::new(Mutex::new(HashMap::new())),
1196        async_runs: Arc::new(Mutex::new(HashMap::new())),
1197        registry: Arc::new(Mutex::new(registry)),
1198        registry_path,
1199        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1200        server_mode,
1201        tls_enabled: sec.tls_enabled,
1202        api_keys: Arc::new(sec.api_keys),
1203        rate_limiter: sec.rate_limiter,
1204        trust_proxy: sec.trust_proxy,
1205        trusted_proxy_ips: sec.trusted_proxy_ips,
1206        git_clones_dir,
1207        schedules: Arc::new(Mutex::new(schedules)),
1208        schedules_path,
1209        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1210        scan_profiles_path,
1211        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1212        confluence: Arc::new(Mutex::new(confluence)),
1213        confluence_path,
1214        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1215        watched_dirs_path,
1216        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1217        cleanup_policy_path,
1218        cleanup_task_handle: Arc::new(Mutex::new(None)),
1219    };
1220
1221    restart_poll_schedules(&state).await;
1222
1223    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1224    {
1225        let enabled = state
1226            .cleanup_policy
1227            .lock()
1228            .await
1229            .policy
1230            .as_ref()
1231            .is_some_and(|p| p.enabled);
1232        if enabled {
1233            let handle = spawn_cleanup_policy_task(state.clone());
1234            *state.cleanup_task_handle.lock().await = Some(handle);
1235        }
1236    }
1237
1238    let app = build_router(state.clone());
1239
1240    // Try the configured port first, then step up through a few alternatives.
1241    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1242    // kernel zombie (visible in netstat but owned by no living process).  Rather
1243    // than failing, we auto-select the next free port and tell the user.
1244    let preferred: SocketAddr = bind_address
1245        .parse()
1246        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1247    let (listener, addr) = {
1248        let candidates = (0u16..=9).map(|offset| {
1249            let mut a = preferred;
1250            a.set_port(preferred.port().saturating_add(offset));
1251            a
1252        });
1253        let mut found = None;
1254        for candidate in candidates {
1255            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1256                found = Some((l, candidate));
1257                break;
1258            }
1259        }
1260        found.ok_or_else(|| {
1261            anyhow::anyhow!(
1262                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1263                bind_address,
1264                preferred.port(),
1265                preferred.port().saturating_add(9)
1266            )
1267        })?
1268    };
1269    if addr != preferred {
1270        eprintln!(
1271            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1272             using {} instead.",
1273            preferred.port(),
1274            addr.port()
1275        );
1276    }
1277
1278    if sec.tls_enabled {
1279        let cert_path = sec
1280            .tls_cert
1281            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1282        let key_path = sec
1283            .tls_key
1284            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1285        let tls_config = build_tls_config(&cert_path, &key_path)
1286            .context("failed to load TLS certificate/key")?;
1287        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1288
1289        let url = format!("https://{addr}/");
1290        println!("OxideSLOC server running at {url} (TLS)");
1291        println!("Use Ctrl+C to stop.");
1292
1293        return serve_tls(listener, app, acceptor, server_mode).await;
1294    }
1295
1296    let url = format!("http://{addr}/");
1297    log_startup_url(&url, server_mode);
1298
1299    axum::serve(
1300        listener,
1301        app.into_make_service_with_connect_info::<SocketAddr>(),
1302    )
1303    .with_graceful_shutdown(shutdown_signal(server_mode))
1304    .await
1305    .context("web server terminated unexpectedly")
1306}
1307
1308/// Discover the primary non-loopback IPv4 address by asking the OS which
1309/// outbound interface it would use to reach a public address.  No packets are
1310/// sent — the UDP socket is only used to query the routing table.
1311fn primary_lan_ip() -> Option<String> {
1312    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1313    socket.connect("8.8.8.8:80").ok()?;
1314    let addr = socket.local_addr().ok()?;
1315    let ip = addr.ip();
1316    if ip.is_loopback() {
1317        return None;
1318    }
1319    Some(ip.to_string())
1320}
1321
1322/// Print the startup URL and, in local mode, open the browser and schedule it.
1323fn log_startup_url(url: &str, server_mode: bool) {
1324    if server_mode {
1325        println!("OxideSLOC server running at {url}");
1326        println!("Use Ctrl+C to stop.");
1327    } else {
1328        println!("OxideSLOC local web UI running at {url}");
1329        println!("Press Ctrl+C to stop the server.");
1330        let open_url = url.to_owned();
1331        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1332    }
1333}
1334
1335/// Open the given URL in the default system browser.
1336fn open_browser_tab(url: &str) {
1337    #[cfg(target_os = "windows")]
1338    let _ = std::process::Command::new("cmd")
1339        .args(["/c", "start", "", url])
1340        .stdout(Stdio::null())
1341        .stderr(Stdio::null())
1342        .spawn();
1343    #[cfg(target_os = "macos")]
1344    let _ = std::process::Command::new("open")
1345        .arg(url)
1346        .stdout(Stdio::null())
1347        .stderr(Stdio::null())
1348        .spawn();
1349    #[cfg(target_os = "linux")]
1350    let _ = std::process::Command::new("xdg-open")
1351        .arg(url)
1352        .stdout(Stdio::null())
1353        .stderr(Stdio::null())
1354        .spawn();
1355}
1356
1357/// Graceful-shutdown future: resolves on Ctrl-C.
1358async fn shutdown_signal(server_mode: bool) {
1359    if tokio::signal::ctrl_c().await.is_ok() {
1360        println!();
1361        if server_mode {
1362            println!("Shutting down OxideSLOC server...");
1363        } else {
1364            println!("Shutting down OxideSLOC local web UI...");
1365        }
1366        println!("Server stopped cleanly.");
1367    }
1368}
1369
1370/// Load a rustls `ServerConfig` from PEM certificate and key files.
1371fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1372    use rustls_pki_types::pem::PemObject;
1373    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1374
1375    let cert_bytes =
1376        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1377    let key_bytes =
1378        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1379
1380    let cert_chain: Vec<CertificateDer<'static>> =
1381        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1382            .collect::<std::result::Result<_, _>>()
1383            .context("failed to parse TLS certificates")?;
1384
1385    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1386        .context("failed to parse TLS private key")?;
1387
1388    rustls::ServerConfig::builder()
1389        .with_no_client_auth()
1390        .with_single_cert(cert_chain, key)
1391        .context("failed to build TLS server config")
1392}
1393
1394/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1395async fn serve_tls(
1396    listener: tokio::net::TcpListener,
1397    app: Router,
1398    acceptor: tokio_rustls::TlsAcceptor,
1399    server_mode: bool,
1400) -> Result<()> {
1401    use hyper_util::rt::{TokioExecutor, TokioIo};
1402    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1403    use hyper_util::service::TowerToHyperService;
1404    use tower::{Service, ServiceExt};
1405
1406    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1407
1408    loop {
1409        tokio::select! {
1410            biased;
1411            _ = tokio::signal::ctrl_c() => {
1412                println!();
1413                if server_mode {
1414                    println!("Shutting down OxideSLOC server...");
1415                } else {
1416                    println!("Shutting down OxideSLOC local web UI...");
1417                }
1418                println!("Server stopped cleanly.");
1419                return Ok(());
1420            }
1421            result = listener.accept() => {
1422                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1423                let acceptor = acceptor.clone();
1424                let mut factory = make_svc.clone();
1425
1426                tokio::spawn(async move {
1427                    let tls = match acceptor.accept(tcp).await {
1428                        Ok(s) => s,
1429                        Err(e) => {
1430                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1431                            return;
1432                        }
1433                    };
1434                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1435                        Ok(f) => match Service::call(f, peer_addr).await {
1436                            Ok(s) => s,
1437                            Err(_) => return,
1438                        },
1439                        Err(_) => return,
1440                    };
1441                    let io = TokioIo::new(tls);
1442                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1443                        .serve_connection(io, TowerToHyperService::new(svc))
1444                        .await
1445                    {
1446                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1447                    }
1448                });
1449            }
1450        }
1451    }
1452}
1453
1454// auth moved to auth.rs
1455
1456fn build_cors_layer(server_mode: bool) -> CorsLayer {
1457    if server_mode {
1458        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1459            .unwrap_or_default()
1460            .split(',')
1461            .filter(|s| !s.is_empty())
1462            .filter_map(|s| s.trim().parse().ok())
1463            .collect();
1464        if allowed.is_empty() {
1465            return CorsLayer::new();
1466        }
1467        CorsLayer::new()
1468            .allow_origin(AllowOrigin::list(allowed))
1469            .allow_methods(AllowMethods::list([
1470                axum::http::Method::GET,
1471                axum::http::Method::POST,
1472            ]))
1473            .allow_headers(AllowHeaders::list([
1474                axum::http::header::AUTHORIZATION,
1475                axum::http::header::CONTENT_TYPE,
1476            ]))
1477    } else {
1478        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1479            let s = origin.to_str().unwrap_or("");
1480            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1481        }))
1482    }
1483}
1484
1485async fn add_security_headers(
1486    State(state): State<AppState>,
1487    mut req: Request<Body>,
1488    next: Next,
1489) -> Response {
1490    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1491    req.extensions_mut().insert(CspNonce(nonce.clone()));
1492    let mut resp = next.run(req).await;
1493    let h = resp.headers_mut();
1494    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1495    h.insert(
1496        "X-Content-Type-Options",
1497        HeaderValue::from_static("nosniff"),
1498    );
1499    h.insert(
1500        "Referrer-Policy",
1501        HeaderValue::from_static("strict-origin-when-cross-origin"),
1502    );
1503    let csp = format!(
1504        "default-src 'self'; \
1505         style-src 'self' 'unsafe-inline'; \
1506         img-src 'self' data: blob:; \
1507         script-src 'self' 'nonce-{nonce}'; \
1508         font-src 'self' data:; \
1509         object-src 'none'; \
1510         frame-ancestors 'none'"
1511    );
1512    h.insert(
1513        "Content-Security-Policy",
1514        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1515            HeaderValue::from_static(
1516                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1517            )
1518        }),
1519    );
1520    h.insert(
1521        "X-Permitted-Cross-Domain-Policies",
1522        HeaderValue::from_static("none"),
1523    );
1524    h.insert(
1525        "Permissions-Policy",
1526        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1527    );
1528    h.insert(
1529        "Cross-Origin-Opener-Policy",
1530        HeaderValue::from_static("same-origin"),
1531    );
1532    h.insert(
1533        "Cross-Origin-Resource-Policy",
1534        HeaderValue::from_static("same-origin"),
1535    );
1536    if state.tls_enabled {
1537        h.insert(
1538            "Strict-Transport-Security",
1539            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1540        );
1541    }
1542    resp
1543}
1544
1545async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1546    let peer_ip = req
1547        .extensions()
1548        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1549        .map(|c| c.0.ip());
1550
1551    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1552    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1553    // header spoofing from direct connections.
1554    let ip = peer_ip
1555        .and_then(|peer| {
1556            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1557                req.headers()
1558                    .get("X-Forwarded-For")
1559                    .and_then(|v| v.to_str().ok())
1560                    .and_then(|s| s.split(',').next())
1561                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1562            } else {
1563                None
1564            }
1565        })
1566        .or(peer_ip)
1567        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1568
1569    if !state.rate_limiter.is_allowed(ip) {
1570        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1571            path = %req.uri().path(), "Rate limit exceeded");
1572        return (
1573            StatusCode::TOO_MANY_REQUESTS,
1574            [(header::RETRY_AFTER, "60")],
1575            "429 Too Many Requests\n",
1576        )
1577            .into_response();
1578    }
1579    next.run(req).await
1580}
1581
1582async fn splash(
1583    State(state): State<AppState>,
1584    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1585) -> impl IntoResponse {
1586    let lan_ip = if state.server_mode {
1587        primary_lan_ip()
1588    } else {
1589        None
1590    };
1591    let port = state
1592        .base_config
1593        .web
1594        .bind_address
1595        .rsplit(':')
1596        .next()
1597        .and_then(|p| p.parse::<u16>().ok())
1598        .unwrap_or(4317);
1599    let has_api_key = !state.api_keys.is_empty();
1600    let template = SplashTemplate {
1601        csp_nonce,
1602        server_mode: state.server_mode,
1603        lan_ip,
1604        port,
1605        version: env!("CARGO_PKG_VERSION"),
1606        has_api_key,
1607    };
1608    Html(
1609        template
1610            .render()
1611            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1612    )
1613}
1614
1615async fn index(
1616    State(state): State<AppState>,
1617    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1618    Query(query): Query<IndexQuery>,
1619) -> impl IntoResponse {
1620    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1621        let policy = query
1622            .mixed_line_policy
1623            .unwrap_or_else(|| "code_only".to_string());
1624        let behavior = query
1625            .binary_file_behavior
1626            .unwrap_or_else(|| "skip".to_string());
1627        let cfg = ScanConfig {
1628            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1629            path: query.path.unwrap_or_default(),
1630            include_globs: query.include_globs.unwrap_or_default(),
1631            exclude_globs: query.exclude_globs.unwrap_or_default(),
1632            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1633            mixed_line_policy: policy,
1634            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1635                != Some("off"),
1636            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1637            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1638            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1639                != Some("disabled"),
1640            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1641            binary_file_behavior: behavior,
1642            output_dir: query.output_dir.unwrap_or_default(),
1643            report_title: query.report_title.unwrap_or_default(),
1644        };
1645        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1646    } else {
1647        "{}".to_string()
1648    };
1649
1650    let git_repo = query.git_repo.unwrap_or_default();
1651    let git_ref = query.git_ref.unwrap_or_default();
1652
1653    let git_label = make_git_label(&git_repo, &git_ref);
1654    let git_output_dir = if git_label.is_empty() {
1655        String::new()
1656    } else {
1657        desktop_dir().join(&git_label).display().to_string()
1658    };
1659    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1660    let git_output_dir_json =
1661        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1662
1663    let template = IndexTemplate {
1664        version: env!("CARGO_PKG_VERSION"),
1665        prefill_json,
1666        csp_nonce,
1667        git_repo,
1668        git_ref,
1669        git_label_json,
1670        git_output_dir_json,
1671        server_mode: state.server_mode,
1672    };
1673
1674    Html(
1675        template
1676            .render()
1677            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1678    )
1679}
1680
1681async fn scan_setup_handler(
1682    State(state): State<AppState>,
1683    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1684) -> impl IntoResponse {
1685    let recent_scans_json = {
1686        let arr: Vec<serde_json::Value> = {
1687            let reg = state.registry.lock().await;
1688            reg.entries
1689                .iter()
1690                .rev()
1691                .take(6)
1692                .map(|e| {
1693                    let run_dir = e
1694                        .html_path
1695                        .as_ref()
1696                        .or(e.json_path.as_ref())
1697                        .and_then(|p| p.parent().map(PathBuf::from));
1698                    let config_val: Option<serde_json::Value> = run_dir
1699                        .and_then(|d| find_scan_config_in_dir(&d))
1700                        .and_then(|p| fs::read_to_string(&p).ok())
1701                        .and_then(|s| serde_json::from_str(&s).ok());
1702                    serde_json::json!({
1703                        "project_label": e.project_label,
1704                        "timestamp": fmt_la_time(e.timestamp_utc),
1705                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1706                        "config": config_val,
1707                    })
1708                })
1709                .collect()
1710        };
1711        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1712    };
1713
1714    let template = ScanSetupTemplate {
1715        version: env!("CARGO_PKG_VERSION"),
1716        recent_scans_json,
1717        csp_nonce,
1718    };
1719    Html(
1720        template
1721            .render()
1722            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1723    )
1724}
1725
1726async fn healthz() -> &'static str {
1727    "ok"
1728}
1729
1730async fn api_version_handler() -> impl IntoResponse {
1731    axum::Json(serde_json::json!({
1732        "name": "oxide-sloc",
1733        "version": env!("CARGO_PKG_VERSION"),
1734    }))
1735}
1736
1737// ── Prometheus metrics ────────────────────────────────────────────────────────
1738
1739fn prom_runs_total() -> &'static prometheus::IntCounter {
1740    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1741    COUNTER.get_or_init(|| {
1742        prometheus::register_int_counter!(
1743            "oxide_sloc_runs_total",
1744            "Total number of completed analysis runs"
1745        )
1746        .expect("failed to register oxide_sloc_runs_total counter")
1747    })
1748}
1749
1750async fn metrics_handler() -> impl IntoResponse {
1751    use prometheus::Encoder as _;
1752    let mut buf = Vec::new();
1753    let encoder = prometheus::TextEncoder::new();
1754    let _ = encoder.encode(&prometheus::gather(), &mut buf);
1755    (
1756        [(
1757            axum::http::header::CONTENT_TYPE,
1758            "text/plain; version=0.0.4; charset=utf-8",
1759        )],
1760        buf,
1761    )
1762}
1763
1764static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1765
1766async fn openapi_yaml_handler() -> impl IntoResponse {
1767    (
1768        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1769        OPENAPI_YAML,
1770    )
1771}
1772
1773static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
1774static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
1775
1776async fn llms_txt_handler() -> impl IntoResponse {
1777    (
1778        [
1779            (
1780                axum::http::header::CONTENT_TYPE,
1781                "text/plain; charset=utf-8",
1782            ),
1783            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1784        ],
1785        LLMS_TXT,
1786    )
1787}
1788
1789async fn llms_full_txt_handler() -> impl IntoResponse {
1790    (
1791        [
1792            (
1793                axum::http::header::CONTENT_TYPE,
1794                "text/plain; charset=utf-8",
1795            ),
1796            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1797        ],
1798        LLMS_FULL_TXT,
1799    )
1800}
1801
1802async fn api_docs_handler(
1803    State(state): State<AppState>,
1804    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1805) -> impl IntoResponse {
1806    let has_api_key = !state.api_keys.is_empty();
1807    Html(
1808        ApiDocsTemplate {
1809            has_api_key,
1810            csp_nonce,
1811            version: env!("CARGO_PKG_VERSION"),
1812        }
1813        .render()
1814        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1815    )
1816}
1817
1818async fn chart_js_handler() -> impl IntoResponse {
1819    (
1820        [
1821            (
1822                header::CONTENT_TYPE,
1823                "application/javascript; charset=utf-8",
1824            ),
1825            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1826        ],
1827        CHART_JS,
1828    )
1829}
1830
1831async fn report_chart_js_handler() -> impl IntoResponse {
1832    (
1833        [
1834            (
1835                header::CONTENT_TYPE,
1836                "application/javascript; charset=utf-8",
1837            ),
1838            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1839        ],
1840        REPORT_CHART_JS,
1841    )
1842}
1843
1844#[derive(Debug, Deserialize)]
1845struct AnalyzeForm {
1846    path: String,
1847    git_repo: Option<String>,
1848    git_ref: Option<String>,
1849    mixed_line_policy: Option<MixedLinePolicy>,
1850    python_docstrings_as_comments: Option<String>,
1851    generated_file_detection: Option<String>,
1852    minified_file_detection: Option<String>,
1853    vendor_directory_detection: Option<String>,
1854    include_lockfiles: Option<String>,
1855    binary_file_behavior: Option<BinaryFileBehavior>,
1856    output_dir: Option<String>,
1857    report_title: Option<String>,
1858    report_header_footer: Option<String>,
1859    include_globs: Option<String>,
1860    exclude_globs: Option<String>,
1861    submodule_breakdown: Option<String>,
1862    coverage_file: Option<String>,
1863    continuation_line_policy: Option<ContinuationLinePolicy>,
1864    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1865    count_compiler_directives: Option<String>,
1866    style_col_threshold: Option<String>,
1867    style_analysis_enabled: Option<String>,
1868    style_score_threshold: Option<String>,
1869    style_lang_scope: Option<String>,
1870    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
1871    cocomo_mode: Option<String>,
1872    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
1873    complexity_alert: Option<String>,
1874    /// Whether to exclude duplicate files from displayed SLOC totals.
1875    exclude_duplicates: Option<String>,
1876}
1877
1878#[allow(clippy::struct_excessive_bools)]
1879#[derive(Debug, Serialize, Deserialize, Clone)]
1880struct ScanConfig {
1881    oxide_sloc_version: String,
1882    path: String,
1883    include_globs: String,
1884    exclude_globs: String,
1885    submodule_breakdown: bool,
1886    mixed_line_policy: String,
1887    python_docstrings_as_comments: bool,
1888    generated_file_detection: bool,
1889    minified_file_detection: bool,
1890    vendor_directory_detection: bool,
1891    include_lockfiles: bool,
1892    binary_file_behavior: String,
1893    output_dir: String,
1894    report_title: String,
1895}
1896
1897#[derive(Debug, Deserialize, Default)]
1898struct IndexQuery {
1899    path: Option<String>,
1900    include_globs: Option<String>,
1901    exclude_globs: Option<String>,
1902    submodule_breakdown: Option<String>,
1903    mixed_line_policy: Option<String>,
1904    python_docstrings_as_comments: Option<String>,
1905    generated_file_detection: Option<String>,
1906    minified_file_detection: Option<String>,
1907    vendor_directory_detection: Option<String>,
1908    include_lockfiles: Option<String>,
1909    binary_file_behavior: Option<String>,
1910    output_dir: Option<String>,
1911    report_title: Option<String>,
1912    prefilled: Option<String>,
1913    git_repo: Option<String>,
1914    git_ref: Option<String>,
1915}
1916
1917#[derive(Debug, Deserialize)]
1918struct PreviewQuery {
1919    path: Option<String>,
1920    include_globs: Option<String>,
1921    exclude_globs: Option<String>,
1922}
1923
1924#[cfg(feature = "native-dialog")]
1925#[derive(Debug, Deserialize)]
1926struct PickDirectoryQuery {
1927    kind: Option<String>,
1928    current: Option<String>,
1929}
1930
1931#[cfg(not(feature = "native-dialog"))]
1932#[derive(Debug, Deserialize)]
1933struct PickDirectoryQuery {}
1934
1935#[derive(Debug, Deserialize, Default)]
1936struct ArtifactQuery {
1937    download: Option<String>,
1938}
1939
1940#[cfg(feature = "native-dialog")]
1941#[derive(Debug, Serialize)]
1942struct PickDirectoryResponse {
1943    selected_path: Option<String>,
1944    cancelled: bool,
1945}
1946
1947#[cfg(feature = "native-dialog")]
1948async fn pick_directory_handler(
1949    State(state): State<AppState>,
1950    Query(query): Query<PickDirectoryQuery>,
1951) -> Response {
1952    if state.server_mode {
1953        return StatusCode::NOT_FOUND.into_response();
1954    }
1955    // Return immediately without opening a dialog in headless / CI environments.
1956    if std::env::var("SLOC_HEADLESS").is_ok() {
1957        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1958            .into_response();
1959    }
1960
1961    let is_coverage = query.kind.as_deref() == Some("coverage");
1962    let title = match query.kind.as_deref() {
1963        Some("output") => "Select output directory",
1964        Some("reports") => "Select folder containing saved reports",
1965        Some("coverage") => "Select LCOV coverage file",
1966        _ => "Select project directory",
1967    }
1968    .to_owned();
1969    let current = query.current.clone();
1970
1971    let picked = tokio::task::spawn_blocking(move || {
1972        // Windows: attach to the foreground thread so the dialog inherits focus,
1973        // and kick off a watcher that flashes the dialog once it appears.
1974        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1975        let fg_tid = win_dialog_focus::attach_to_foreground();
1976        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1977        win_dialog_focus::flash_dialog_when_ready(title.clone());
1978
1979        let mut dialog = rfd::FileDialog::new().set_title(&title);
1980        if let Some(current) = current.as_deref() {
1981            let resolved = resolve_input_path(current);
1982            let seed = if resolved.is_dir() {
1983                Some(resolved)
1984            } else {
1985                resolved.parent().map(Path::to_path_buf)
1986            };
1987            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1988                dialog = dialog.set_directory(seed_dir);
1989            }
1990        }
1991        let result = if is_coverage {
1992            dialog
1993                .add_filter(
1994                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1995                    &["info", "lcov", "xml"],
1996                )
1997                .pick_file()
1998        } else {
1999            dialog.pick_folder()
2000        };
2001
2002        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2003        win_dialog_focus::detach_from_foreground(fg_tid);
2004
2005        result
2006    })
2007    .await
2008    .unwrap_or(None);
2009
2010    Json(PickDirectoryResponse {
2011        selected_path: picked.as_ref().map(|p| display_path(p)),
2012        cancelled: picked.is_none(),
2013    })
2014    .into_response()
2015}
2016
2017#[cfg(not(feature = "native-dialog"))]
2018async fn pick_directory_handler(
2019    State(_state): State<AppState>,
2020    Query(_query): Query<PickDirectoryQuery>,
2021) -> Response {
2022    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2023}
2024
2025#[cfg(feature = "native-dialog")]
2026async fn pick_file_handler(State(state): State<AppState>) -> Response {
2027    if state.server_mode {
2028        return StatusCode::NOT_FOUND.into_response();
2029    }
2030    if std::env::var("SLOC_HEADLESS").is_ok() {
2031        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2032            .into_response();
2033    }
2034    let picked = tokio::task::spawn_blocking(|| {
2035        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2036        let fg_tid = win_dialog_focus::attach_to_foreground();
2037        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2038        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2039
2040        let result = rfd::FileDialog::new()
2041            .set_title("Select HTML report")
2042            .add_filter("HTML report", &["html"])
2043            .pick_file();
2044
2045        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2046        win_dialog_focus::detach_from_foreground(fg_tid);
2047
2048        result
2049    })
2050    .await
2051    .unwrap_or(None);
2052    Json(PickDirectoryResponse {
2053        selected_path: picked.as_ref().map(|p| display_path(p)),
2054        cancelled: picked.is_none(),
2055    })
2056    .into_response()
2057}
2058
2059#[cfg(not(feature = "native-dialog"))]
2060async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2061    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2062}
2063
2064// ── Browser-upload handlers (server mode only) ────────────────────────────────
2065
2066/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2067/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2068fn is_upload_tmp_path(path: &Path) -> bool {
2069    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2070    path.starts_with(&upload_root)
2071}
2072
2073/// Returns true when `path` is the built-in sample or test-fixture directory.
2074/// These paths ship with the server binary and are always safe to scan/preview.
2075fn is_sample_path(path: &Path) -> bool {
2076    let root = workspace_root();
2077    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2078}
2079
2080/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2081fn upload_base_dir() -> PathBuf {
2082    std::env::temp_dir().join("oxide-sloc-uploads")
2083}
2084
2085/// Returns the staging path for a given upload id inside the base dir.
2086fn upload_staging_path(id: &str) -> PathBuf {
2087    upload_base_dir().join(id)
2088}
2089
2090/// Validate basic field constraints on a directory-upload request.
2091/// Returns an error `Response` if the request should be rejected immediately.
2092#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2093fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2094    const MAX_FILES: usize = 50_000;
2095    if body.files.is_empty() {
2096        return Err((
2097            StatusCode::BAD_REQUEST,
2098            Json(serde_json::json!({"error": "No files received"})),
2099        )
2100            .into_response());
2101    }
2102    if body.files.len() > MAX_FILES {
2103        return Err((
2104            StatusCode::PAYLOAD_TOO_LARGE,
2105            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2106        )
2107            .into_response());
2108    }
2109    Ok(())
2110}
2111
2112/// Resolve or create the staging directory for a directory upload.
2113/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2114fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2115    match id {
2116        Some(id)
2117            if !id.is_empty()
2118                && id.len() <= 36
2119                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2120        {
2121            (id.to_string(), upload_staging_path(id))
2122        }
2123        _ => {
2124            let new_id = uuid::Uuid::new_v4().to_string();
2125            let staging = upload_staging_path(&new_id);
2126            (new_id, staging)
2127        }
2128    }
2129}
2130
2131/// Decode, size-check, and write one uploaded file entry into `staging`.
2132/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2133/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2134/// cleaning up `staging` before propagating the error.
2135#[allow(clippy::result_large_err)]
2136async fn stage_decoded_entry(
2137    entry: &UploadedFile,
2138    staging: &Path,
2139    total_bytes: &mut usize,
2140    project_root: &mut Option<PathBuf>,
2141) -> Result<(), Response> {
2142    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2143
2144    let Ok(data) = base64::Engine::decode(
2145        &base64::engine::general_purpose::STANDARD,
2146        entry.content.as_bytes(),
2147    ) else {
2148        return Ok(());
2149    };
2150
2151    *total_bytes += data.len();
2152    if *total_bytes > MAX_TOTAL_BYTES {
2153        return Err((
2154            StatusCode::PAYLOAD_TOO_LARGE,
2155            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2156        )
2157            .into_response());
2158    }
2159
2160    let rel = std::path::Path::new(&entry.path);
2161    if project_root.is_none() {
2162        if let Some(first) = rel.components().next() {
2163            *project_root = Some(staging.join(first.as_os_str()));
2164        }
2165    }
2166
2167    let dest = staging.join(rel);
2168    if let Some(parent) = dest.parent() {
2169        if tokio::fs::create_dir_all(parent).await.is_err() {
2170            return Err((
2171                StatusCode::INTERNAL_SERVER_ERROR,
2172                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2173            )
2174                .into_response());
2175        }
2176    }
2177
2178    if tokio::fs::write(&dest, &data).await.is_err() {
2179        return Err((
2180            StatusCode::INTERNAL_SERVER_ERROR,
2181            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2182        )
2183            .into_response());
2184    }
2185
2186    Ok(())
2187}
2188
2189/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2190/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2191/// an error `Response` on failure (staging dir is cleaned up before returning).
2192async fn write_upload_files(
2193    files: &[UploadedFile],
2194    staging: &Path,
2195    upload_id: &str,
2196) -> Result<(usize, Option<PathBuf>), Response> {
2197    let mut total_bytes: usize = 0;
2198    let mut project_root: Option<PathBuf> = None;
2199    let mut traversal_attempts: usize = 0;
2200
2201    for entry in files {
2202        let rel = std::path::Path::new(&entry.path);
2203        if rel
2204            .components()
2205            .any(|c| matches!(c, std::path::Component::ParentDir))
2206        {
2207            traversal_attempts += 1;
2208            if traversal_attempts >= 5 {
2209                let _ = tokio::fs::remove_dir_all(staging).await;
2210                tracing::warn!(
2211                    event = "upload_path_traversal",
2212                    upload_id = %upload_id,
2213                    "Upload rejected: repeated path traversal attempts detected"
2214                );
2215                return Err((
2216                    StatusCode::BAD_REQUEST,
2217                    Json(serde_json::json!({"error": "Upload rejected"})),
2218                )
2219                    .into_response());
2220            }
2221            continue;
2222        }
2223
2224        if let Err(resp) =
2225            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2226        {
2227            let _ = tokio::fs::remove_dir_all(staging).await;
2228            return Err(resp);
2229        }
2230    }
2231
2232    Ok((files.len(), project_root))
2233}
2234
2235/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2236/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2237fn parse_tarball_size_caps() -> (u64, u64) {
2238    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2239        .ok()
2240        .and_then(|v| v.parse().ok())
2241        .unwrap_or(2048_u64)
2242        * 1024
2243        * 1024;
2244    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2245        .ok()
2246        .and_then(|v| v.parse().ok())
2247        .unwrap_or(10_240_u64)
2248        * 1024
2249        * 1024;
2250    (compressed, decompressed)
2251}
2252
2253/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2254/// Returns the number of compressed bytes written, or an error `Response`.
2255/// Cleans up `dest_path` on error.
2256#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2257async fn stream_body_to_file(
2258    body: axum::body::Body,
2259    dest_path: &Path,
2260    max_bytes: u64,
2261) -> Result<u64, Response> {
2262    use http_body_util::BodyExt as _;
2263    use tokio::io::AsyncWriteExt as _;
2264
2265    let mut file = match tokio::fs::File::create(dest_path).await {
2266        Ok(f) => f,
2267        Err(e) => {
2268            tracing::error!(
2269                event = "upload_io_error",
2270                "failed to create tarball temp file: {e}"
2271            );
2272            return Err((
2273                StatusCode::INTERNAL_SERVER_ERROR,
2274                Json(serde_json::json!({"error": "Upload initialization failed"})),
2275            )
2276                .into_response());
2277        }
2278    };
2279
2280    let mut body = body;
2281    let mut written: u64 = 0;
2282    loop {
2283        match body.frame().await {
2284            None => break,
2285            Some(Err(e)) => {
2286                let _ = tokio::fs::remove_file(dest_path).await;
2287                return Err((
2288                    StatusCode::BAD_REQUEST,
2289                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2290                )
2291                    .into_response());
2292            }
2293            Some(Ok(frame)) => {
2294                if let Ok(data) = frame.into_data() {
2295                    written += data.len() as u64;
2296                    if written > max_bytes {
2297                        let _ = tokio::fs::remove_file(dest_path).await;
2298                        return Err((
2299                            StatusCode::PAYLOAD_TOO_LARGE,
2300                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2301                        )
2302                            .into_response());
2303                    }
2304                    if let Err(e) = file.write_all(&data).await {
2305                        let _ = tokio::fs::remove_file(dest_path).await;
2306                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2307                        return Err((
2308                            StatusCode::INTERNAL_SERVER_ERROR,
2309                            Json(serde_json::json!({"error": "Upload write failed"})),
2310                        )
2311                            .into_response());
2312                    }
2313                }
2314            }
2315        }
2316    }
2317    drop(file);
2318    Ok(written)
2319}
2320
2321/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2322/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2323/// on failure (staging dir is cleaned up before returning).
2324#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2325async fn extract_tarball_to_staging(
2326    tarball_path: &Path,
2327    staging: &Path,
2328    max_decompressed_bytes: u64,
2329) -> Result<(), Response> {
2330    let staging_clone = staging.to_path_buf();
2331    let tarball_clone = tarball_path.to_path_buf();
2332    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2333        let file = std::fs::File::open(&tarball_clone)?;
2334        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2335        let limited = SizeLimitReader {
2336            inner: gz,
2337            remaining: max_decompressed_bytes,
2338        };
2339        let mut archive = tar::Archive::new(limited);
2340        archive.set_overwrite(true);
2341        archive.set_preserve_permissions(false);
2342        std::fs::create_dir_all(&staging_clone)?;
2343        archive.unpack(&staging_clone)?;
2344        Ok(())
2345    })
2346    .await;
2347    let _ = tokio::fs::remove_file(tarball_path).await;
2348
2349    match extract_result {
2350        Ok(Ok(())) => Ok(()),
2351        Ok(Err(e)) => {
2352            let _ = tokio::fs::remove_dir_all(staging).await;
2353            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2354            tracing::warn!(
2355                event = "upload_extract_error",
2356                "tarball extraction failed: {e:#}"
2357            );
2358            let (status, msg) = if is_size_limit {
2359                (
2360                    StatusCode::PAYLOAD_TOO_LARGE,
2361                    "Archive exceeds the decompressed size limit",
2362                )
2363            } else {
2364                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2365            };
2366            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2367        }
2368        Err(e) => {
2369            let _ = tokio::fs::remove_dir_all(staging).await;
2370            tracing::error!(
2371                event = "upload_extract_panic",
2372                "tarball extraction task panicked: {e}"
2373            );
2374            Err((
2375                StatusCode::INTERNAL_SERVER_ERROR,
2376                Json(serde_json::json!({"error": "Archive extraction failed"})),
2377            )
2378                .into_response())
2379        }
2380    }
2381}
2382
2383/// If `staging` contains exactly one top-level directory, return its path
2384/// (the common case when the archive was created with `webkitRelativePath`).
2385/// Otherwise return `None`.
2386async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2387    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2388    let first = entries.next_entry().await.ok()??;
2389    if !first.path().is_dir() {
2390        return None;
2391    }
2392    if entries.next_entry().await.unwrap_or(None).is_some() {
2393        return None;
2394    }
2395    Some(first.path())
2396}
2397
2398/// Request body for `POST /api/upload-directory`.
2399///
2400/// Each entry carries a relative path (identical to the browser's
2401/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2402/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2403/// avoids pulling in a `multipart` library that is not in the vendor archive.
2404#[derive(Deserialize)]
2405struct UploadDirRequest {
2406    files: Vec<UploadedFile>,
2407    /// If provided, append this batch to an existing upload session instead of
2408    /// creating a new staging directory. Must be a plain UUID (no path separators).
2409    upload_id: Option<String>,
2410}
2411
2412#[derive(Deserialize)]
2413struct UploadedFile {
2414    /// `webkitRelativePath` value from the browser File object.
2415    path: String,
2416    /// Raw file bytes encoded as standard base64.
2417    content: String,
2418}
2419
2420/// POST /api/upload-directory
2421///
2422/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2423/// Saves all files to a temp staging directory preserving their relative paths,
2424/// then returns the server-side root directory path so the caller can populate
2425/// the scan-path field and run a normal analysis.
2426///
2427/// Only available in server mode; returns 404 in local mode (use the native
2428/// rfd dialog instead).
2429async fn upload_directory_handler(
2430    State(state): State<AppState>,
2431    Json(body): Json<UploadDirRequest>,
2432) -> Response {
2433    if !state.server_mode {
2434        return StatusCode::NOT_FOUND.into_response();
2435    }
2436    if let Err(resp) = validate_upload_dir_request(&body) {
2437        return resp;
2438    }
2439    // Reuse an existing staging dir when the client sends a continuation batch,
2440    // otherwise create a fresh one. Validate the id to prevent path traversal.
2441    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2442    match write_upload_files(&body.files, &staging, &upload_id).await {
2443        Ok((file_count, project_root)) => {
2444            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2445            Json(serde_json::json!({
2446                "tmp_path": scan_root.to_string_lossy(),
2447                "file_count": file_count,
2448                "upload_id": upload_id.clone()
2449            }))
2450            .into_response()
2451        }
2452        Err(resp) => resp,
2453    }
2454}
2455
2456/// Request body for `POST /api/upload-file`.
2457#[derive(Deserialize)]
2458struct UploadFileRequest {
2459    /// Original filename (used only to preserve the extension).
2460    filename: String,
2461    /// File bytes encoded as standard base64.
2462    content: String,
2463}
2464
2465/// POST /api/upload-file
2466///
2467/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2468/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2469/// Only available in server mode.
2470async fn upload_file_handler(
2471    State(state): State<AppState>,
2472    Json(body): Json<UploadFileRequest>,
2473) -> Response {
2474    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2475
2476    if !state.server_mode {
2477        return StatusCode::NOT_FOUND.into_response();
2478    }
2479
2480    let Ok(data) = base64::Engine::decode(
2481        &base64::engine::general_purpose::STANDARD,
2482        body.content.as_bytes(),
2483    ) else {
2484        return (
2485            StatusCode::BAD_REQUEST,
2486            Json(serde_json::json!({"error": "Invalid base64 content"})),
2487        )
2488            .into_response();
2489    };
2490
2491    if data.len() > MAX_FILE_BYTES {
2492        return (
2493            StatusCode::PAYLOAD_TOO_LARGE,
2494            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2495        )
2496            .into_response();
2497    }
2498
2499    // Sanitise: strip any directory component from the filename.
2500    let filename = std::path::Path::new(&body.filename)
2501        .file_name()
2502        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2503
2504    let upload_id = uuid::Uuid::new_v4();
2505    let staging = std::env::temp_dir()
2506        .join("oxide-sloc-uploads")
2507        .join(upload_id.to_string());
2508
2509    if tokio::fs::create_dir_all(&staging).await.is_err() {
2510        return (
2511            StatusCode::INTERNAL_SERVER_ERROR,
2512            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2513        )
2514            .into_response();
2515    }
2516
2517    let dest = staging.join(&filename);
2518    if tokio::fs::write(&dest, &data).await.is_err() {
2519        let _ = tokio::fs::remove_dir_all(&staging).await;
2520        return (
2521            StatusCode::INTERNAL_SERVER_ERROR,
2522            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2523        )
2524            .into_response();
2525    }
2526
2527    Json(serde_json::json!({
2528        "tmp_path": dest.to_string_lossy(),
2529        "upload_id": upload_id.to_string()
2530    }))
2531    .into_response()
2532}
2533
2534/// POST /api/upload-tarball
2535///
2536/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2537/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2538/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2539/// project root. The two size fields power the "Original / Compressed project size" display in the
2540/// web UI.
2541///
2542/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
2543/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
2544/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
2545/// bounded regardless of project size.
2546/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2547/// decompressed. Wraps any `std::io::Read` source.
2548struct SizeLimitReader<R> {
2549    inner: R,
2550    remaining: u64,
2551}
2552impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2553    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2554        if self.remaining == 0 {
2555            return Err(std::io::Error::other("decompressed size limit exceeded"));
2556        }
2557        let n = self.inner.read(buf)?;
2558        self.remaining = self.remaining.saturating_sub(n as u64);
2559        Ok(n)
2560    }
2561}
2562
2563async fn upload_tarball_handler(
2564    State(state): State<AppState>,
2565    request: axum::extract::Request,
2566) -> Response {
2567    if !state.server_mode {
2568        return StatusCode::NOT_FOUND.into_response();
2569    }
2570
2571    let upload_id = uuid::Uuid::new_v4().to_string();
2572    let upload_base = upload_base_dir();
2573    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2574    let staging = upload_staging_path(&upload_id);
2575    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2576
2577    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2578        tracing::error!(
2579            event = "upload_io_error",
2580            "failed to create upload base dir: {e}"
2581        );
2582        return (
2583            StatusCode::INTERNAL_SERVER_ERROR,
2584            Json(serde_json::json!({"error": "Upload initialization failed"})),
2585        )
2586            .into_response();
2587    }
2588
2589    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2590    let compressed_bytes =
2591        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2592            Ok(n) => n,
2593            Err(resp) => return resp,
2594        };
2595
2596    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2597    if let Err(resp) =
2598        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2599    {
2600        return resp;
2601    }
2602
2603    // ── 3. Find the project root inside the staging dir ───────────────────────
2604    // If the tar contained a single top-level directory (the common case when the
2605    // browser uses `webkitRelativePath`), return that as the scan root so the path
2606    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2607    let scan_root = find_single_top_dir(&staging)
2608        .await
2609        .unwrap_or_else(|| staging.clone());
2610
2611    // Compute original (uncompressed) size of the extracted tree.
2612    let original_bytes = tokio::task::spawn_blocking({
2613        let p = scan_root.clone();
2614        move || dir_size_bytes(&p)
2615    })
2616    .await
2617    .unwrap_or(0);
2618
2619    Json(serde_json::json!({
2620        "tmp_path": scan_root.to_string_lossy(),
2621        "upload_id": upload_id,
2622        "compressed_bytes": compressed_bytes,
2623        "original_bytes": original_bytes,
2624    }))
2625    .into_response()
2626}
2627
2628#[derive(Deserialize)]
2629struct LocateReportForm {
2630    file_path: String,
2631    #[serde(default)]
2632    redirect_url: Option<String>,
2633    #[serde(default)]
2634    expected_run_id: Option<String>,
2635}
2636
2637/// Render a view-reports error page and return it as a `Response`.
2638fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2639    let html = ErrorTemplate {
2640        message: message.into(),
2641        last_report_url: Some("/view-reports".to_string()),
2642        last_report_label: Some("View Reports".to_string()),
2643        run_id: None,
2644        error_code: None,
2645        csp_nonce: csp_nonce.to_owned(),
2646        version: env!("CARGO_PKG_VERSION"),
2647    }
2648    .render()
2649    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2650    Html(html).into_response()
2651}
2652
2653/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
2654fn registry_entry_from_run(
2655    run: &AnalysisRun,
2656    json_path: PathBuf,
2657    html_path: PathBuf,
2658) -> RegistryEntry {
2659    let project_label = run.input_roots.first().map_or_else(
2660        || "Unknown Project".to_string(),
2661        |r| sanitize_project_label(r),
2662    );
2663    RegistryEntry {
2664        run_id: run.tool.run_id.clone(),
2665        timestamp_utc: run.tool.timestamp_utc,
2666        project_label,
2667        input_roots: run.input_roots.clone(),
2668        json_path: Some(json_path),
2669        html_path: Some(html_path),
2670        pdf_path: None,
2671        summary: ScanSummarySnapshot {
2672            files_analyzed: run.summary_totals.files_analyzed,
2673            files_skipped: run.summary_totals.files_skipped,
2674            total_physical_lines: run.summary_totals.total_physical_lines,
2675            code_lines: run.summary_totals.code_lines,
2676            comment_lines: run.summary_totals.comment_lines,
2677            blank_lines: run.summary_totals.blank_lines,
2678            functions: run.summary_totals.functions,
2679            classes: run.summary_totals.classes,
2680            variables: run.summary_totals.variables,
2681            imports: run.summary_totals.imports,
2682            test_count: run.summary_totals.test_count,
2683            coverage_lines_found: run.summary_totals.coverage_lines_found,
2684            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2685            coverage_functions_found: run.summary_totals.coverage_functions_found,
2686            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2687            coverage_branches_found: run.summary_totals.coverage_branches_found,
2688            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2689        },
2690        csv_path: None,
2691        xlsx_path: None,
2692        git_branch: None,
2693        git_commit: None,
2694        git_author: None,
2695        git_tags: None,
2696        git_nearest_tag: None,
2697        git_commit_date: None,
2698    }
2699}
2700
2701/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
2702/// immediately without requiring a server restart.
2703pub(crate) async fn register_artifacts_in_registry(
2704    state: &AppState,
2705    label: &str,
2706    run: &AnalysisRun,
2707    artifacts: &RunArtifacts,
2708) {
2709    let Some(json_path) = artifacts.json_path.clone() else {
2710        return;
2711    };
2712    let Some(html_path) = artifacts.html_path.clone() else {
2713        return;
2714    };
2715    let mut entry = registry_entry_from_run(run, json_path, html_path);
2716    entry.project_label = label.to_owned();
2717    let mut reg = state.registry.lock().await;
2718    reg.add_entry(entry);
2719    let _ = reg.save(&state.registry_path);
2720}
2721
2722fn is_html_report_file(p: &Path) -> bool {
2723    p.is_file()
2724        && p.extension()
2725            .and_then(|x| x.to_str())
2726            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2727        && p.file_name()
2728            .and_then(|n| n.to_str())
2729            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2730}
2731
2732fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2733    fs::read_dir(dir)
2734        .ok()?
2735        .flatten()
2736        .map(|e| e.path())
2737        .find(|p| is_html_report_file(p))
2738}
2739
2740fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2741    if let Some(f) = find_html_report_in_dir(dir) {
2742        return Some(f);
2743    }
2744    if let Ok(rd) = fs::read_dir(dir) {
2745        for entry in rd.flatten() {
2746            let sub = entry.path();
2747            if sub.is_dir() {
2748                if let Some(f) = find_html_report_in_dir(&sub) {
2749                    return Some(f);
2750                }
2751            }
2752        }
2753    }
2754    None
2755}
2756
2757/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
2758/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
2759///
2760/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
2761#[allow(clippy::result_large_err)]
2762fn validate_locate_request(
2763    state: &AppState,
2764    file_path: &str,
2765    csp_nonce: &str,
2766) -> Result<(PathBuf, PathBuf), Response> {
2767    let raw = PathBuf::from(file_path);
2768
2769    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
2770    let html_path = if raw.is_dir() {
2771        let found = find_html_report_in_tree(&raw);
2772        match found {
2773            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2774            None => {
2775                return Err(locate_report_error(
2776                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
2777                     the folder that contains your scan output (result_*.html or report_*.html).",
2778                    csp_nonce,
2779                ));
2780            }
2781        }
2782    } else {
2783        let file_ext = raw
2784            .extension()
2785            .and_then(|e| e.to_str())
2786            .unwrap_or("")
2787            .to_ascii_lowercase();
2788        if file_ext != "html" {
2789            return Err(locate_report_error(
2790                "Please select the scan output folder, or an .html report file directly.",
2791                csp_nonce,
2792            ));
2793        }
2794        match fs::canonicalize(&raw) {
2795            Ok(p) => strip_unc_prefix(p),
2796            Err(_) => {
2797                return Err(locate_report_error(
2798                    "Report file not found or path is invalid.",
2799                    csp_nonce,
2800                ));
2801            }
2802        }
2803    };
2804
2805    if state.server_mode {
2806        let output_root = resolve_output_root(None);
2807        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2808        if !html_path.starts_with(&canonical_root) {
2809            return Err(locate_report_error(
2810                "Report file must be within the configured output directory.",
2811                csp_nonce,
2812            ));
2813        }
2814    }
2815    let parent = match html_path.parent() {
2816        Some(p) => p.to_path_buf(),
2817        None => {
2818            return Err(locate_report_error(
2819                "Report file has no parent directory.",
2820                csp_nonce,
2821            ));
2822        }
2823    };
2824    Ok((html_path, parent))
2825}
2826
2827/// JSON-or-HTML error for `locate_report_handler` error paths.
2828fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2829    if want_json {
2830        (
2831            StatusCode::UNPROCESSABLE_ENTITY,
2832            axum::Json(serde_json::json!({"ok": false, "message": msg})),
2833        )
2834            .into_response()
2835    } else {
2836        locate_report_error(msg, csp_nonce)
2837    }
2838}
2839
2840/// JSON-or-redirect success for locate/relocate handler success paths.
2841fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2842    if want_json {
2843        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2844    } else {
2845        axum::response::Redirect::to(redirect).into_response()
2846    }
2847}
2848
2849/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
2850/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
2851fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2852    for jpath in candidates {
2853        if let Ok(run) = read_json(jpath) {
2854            if expected.is_empty() || run.tool.run_id == expected {
2855                return Some((jpath.clone(), run.tool.run_id));
2856            }
2857        }
2858    }
2859    None
2860}
2861
2862fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2863    html_path
2864        .parent()
2865        .and_then(|p| p.parent())
2866        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2867}
2868
2869fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2870    let mut hits = collect_result_json_candidates(scan_root);
2871    if hits.is_empty() {
2872        hits = collect_result_json_candidates(parent);
2873    }
2874    hits.sort();
2875    hits
2876}
2877
2878#[allow(clippy::too_many_lines)]
2879async fn locate_report_handler(
2880    State(state): State<AppState>,
2881    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2882    headers: axum::http::HeaderMap,
2883    Form(form): Form<LocateReportForm>,
2884) -> impl IntoResponse {
2885    let want_json = headers
2886        .get(axum::http::header::ACCEPT)
2887        .and_then(|v| v.to_str().ok())
2888        .is_some_and(|v| v.contains("application/json"));
2889
2890    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2891        Ok(v) => v,
2892        Err(resp) => {
2893            if want_json {
2894                return locate_handler_err(
2895                    true,
2896                    "No HTML report file found in the selected folder. \
2897                     Make sure you selected the folder that contains your \
2898                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
2899                        .to_string(),
2900                    &csp_nonce,
2901                );
2902            }
2903            return resp;
2904        }
2905    };
2906
2907    // Search for result_*.json in the HTML's parent and also its grandparent (handles
2908    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
2909    let scan_root_owned = resolve_scan_root(&html_path, &parent);
2910    let scan_root: &Path = &scan_root_owned;
2911    let json_candidates = gather_json_candidates(scan_root, &parent);
2912
2913    // If the expected_run_id was provided, find a JSON that matches it exactly.
2914    let expected_run_id = form
2915        .expected_run_id
2916        .as_deref()
2917        .unwrap_or("")
2918        .trim()
2919        .to_string();
2920
2921    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2922
2923    // If we have candidates but none matched the expected run_id, surface a clear error.
2924    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2925        let actual = json_candidates
2926            .iter()
2927            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2928            .unwrap_or_else(|| "unknown".to_string());
2929        return locate_handler_err(
2930            want_json,
2931            format!(
2932                "This folder contains a different scan.\n\n\
2933                 Expected run ID : {expected_run_id}\n\
2934                 Found run ID    : {actual}\n\n\
2935                 Please select the folder that contains the correct scan output."
2936            ),
2937            &csp_nonce,
2938        );
2939    }
2940
2941    let safe_redirect = form
2942        .redirect_url
2943        .as_deref()
2944        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2945        .unwrap_or("/view-reports?linked=1")
2946        .to_string();
2947
2948    let mut reg = state.registry.lock().await;
2949
2950    if let Some((json_path, run_id)) = matched_json {
2951        // Match by run_id in the registry (works even after files are moved).
2952        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2953            entry.html_path = Some(html_path);
2954            entry.json_path = Some(json_path);
2955            let _ = reg.save(&state.registry_path);
2956            drop(reg);
2957            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
2958            state.artifacts.lock().await.remove(&run_id);
2959            return redirect_or_json_ok(want_json, &safe_redirect);
2960        }
2961        // No existing entry — build one from the JSON.
2962        match read_json(&json_path) {
2963            Ok(run) => {
2964                let entry = registry_entry_from_run(&run, json_path, html_path);
2965                reg.add_entry(entry);
2966                let _ = reg.save(&state.registry_path);
2967                drop(reg);
2968                state.artifacts.lock().await.remove(&run_id);
2969                return redirect_or_json_ok(want_json, &safe_redirect);
2970            }
2971            Err(e) => {
2972                drop(reg);
2973                return locate_handler_err(
2974                    want_json,
2975                    format!(
2976                        "Found the scan folder but could not parse the result JSON.\n\n\
2977                         The file may have been saved by an older version of OxideSLOC. \
2978                         Re-running the analysis will create a fresh, compatible record.\n\n\
2979                         Error: {e}"
2980                    ),
2981                    &csp_nonce,
2982                );
2983            }
2984        }
2985    }
2986
2987    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
2988    if let Some(entry) = reg
2989        .entries
2990        .iter_mut()
2991        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2992    {
2993        entry.html_path = Some(html_path.clone());
2994        let _ = reg.save(&state.registry_path);
2995        drop(reg);
2996        state.artifacts.lock().await.remove(&expected_run_id);
2997        return redirect_or_json_ok(want_json, &safe_redirect);
2998    }
2999
3000    drop(reg);
3001    let hint = if state.server_mode {
3002        String::new()
3003    } else {
3004        format!(
3005            "\n\nSearched folder : {}\nHTML found      : {}",
3006            scan_root.display(),
3007            html_path.display()
3008        )
3009    };
3010    locate_handler_err(
3011        want_json,
3012        format!(
3013            "Could not link this report.\n\n\
3014             No result_*.json was found in the selected folder. \
3015             Make sure you selected the top-level scan output folder \
3016             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3017        ),
3018        &csp_nonce,
3019    )
3020}
3021
3022/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3023fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3024    fs::read_dir(dir)
3025        .ok()?
3026        .flatten()
3027        .map(|e| e.path())
3028        .find(|p| {
3029            p.is_file()
3030                && p.file_stem()
3031                    .and_then(|n| n.to_str())
3032                    .is_some_and(|n| n.starts_with("result"))
3033                && p.extension()
3034                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3035        })
3036}
3037
3038#[derive(Deserialize)]
3039struct LocateReportsDirForm {
3040    folder_path: String,
3041}
3042
3043#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3044async fn locate_reports_dir_handler(
3045    State(state): State<AppState>,
3046    Form(form): Form<LocateReportsDirForm>,
3047) -> impl IntoResponse {
3048    if state.server_mode {
3049        return StatusCode::NOT_FOUND.into_response();
3050    }
3051    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3052        Ok(p) => strip_unc_prefix(p),
3053        Err(_) => {
3054            return axum::response::Redirect::to(
3055                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3056            )
3057            .into_response();
3058        }
3059    };
3060    if !folder.is_dir() {
3061        return axum::response::Redirect::to(
3062            "/view-reports?error=Selected+path+is+not+a+directory.",
3063        )
3064        .into_response();
3065    }
3066
3067    let candidates = collect_result_json_candidates(&folder);
3068
3069    if candidates.is_empty() {
3070        return axum::response::Redirect::to(
3071            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3072        )
3073        .into_response();
3074    }
3075
3076    let mut linked_count: usize = 0;
3077    let mut reg = state.registry.lock().await;
3078    for json_path in candidates {
3079        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3080            continue;
3081        };
3082        if is_dir_already_registered(&reg, &parent) {
3083            continue;
3084        }
3085        let Some(entry) = build_registry_entry_from_json(json_path) else {
3086            continue;
3087        };
3088        reg.add_entry(entry);
3089        linked_count += 1;
3090    }
3091    let _ = reg.save(&state.registry_path);
3092    drop(reg);
3093
3094    if linked_count == 0 {
3095        return axum::response::Redirect::to(
3096            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3097        )
3098        .into_response();
3099    }
3100    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3101}
3102
3103#[derive(Deserialize)]
3104struct RelocateScanForm {
3105    run_id: String,
3106    folder_path: String,
3107    redirect_url: String,
3108}
3109
3110/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3111/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3112fn relocate_folder_err(
3113    want_json: bool,
3114    status: StatusCode,
3115    msg: &str,
3116    run_id: &str,
3117    folder_hint: &str,
3118    redirect_url: &str,
3119    csp_nonce: &str,
3120) -> Response {
3121    if want_json {
3122        (
3123            status,
3124            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3125        )
3126            .into_response()
3127    } else {
3128        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3129    }
3130}
3131
3132#[allow(clippy::too_many_lines)]
3133async fn relocate_scan_handler(
3134    State(state): State<AppState>,
3135    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3136    headers: axum::http::HeaderMap,
3137    Form(form): Form<RelocateScanForm>,
3138) -> impl IntoResponse {
3139    let want_json = headers
3140        .get(axum::http::header::ACCEPT)
3141        .and_then(|v| v.to_str().ok())
3142        .is_some_and(|v| v.contains("application/json"));
3143    if state.server_mode {
3144        return StatusCode::NOT_FOUND.into_response();
3145    }
3146
3147    let run_id = form.run_id.trim().to_string();
3148    let redirect_url = form.redirect_url.trim().to_string();
3149
3150    let run_exists = {
3151        let reg = state.registry.lock().await;
3152        reg.find_by_run_id(&run_id).is_some()
3153    };
3154    if !run_exists {
3155        if want_json {
3156            return (
3157                StatusCode::NOT_FOUND,
3158                axum::Json(serde_json::json!({
3159                    "ok": false,
3160                    "message": format!("Run ID '{run_id}' not found in registry.")
3161                })),
3162            )
3163                .into_response();
3164        }
3165        let html = ErrorTemplate {
3166            message: format!("Run ID '{run_id}' not found in registry."),
3167            last_report_url: Some("/compare-scans".to_string()),
3168            last_report_label: Some("Compare Scans".to_string()),
3169            run_id: Some(run_id.clone()),
3170            error_code: Some(404),
3171            csp_nonce: csp_nonce.clone(),
3172            version: env!("CARGO_PKG_VERSION"),
3173        }
3174        .render()
3175        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3176        return Html(html).into_response();
3177    }
3178
3179    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3180        Ok(p) => strip_unc_prefix(p),
3181        Err(_) => {
3182            return relocate_folder_err(
3183                want_json,
3184                StatusCode::UNPROCESSABLE_ENTITY,
3185                "Folder not found or path is invalid.",
3186                &run_id,
3187                form.folder_path.trim(),
3188                &redirect_url,
3189                &csp_nonce,
3190            );
3191        }
3192    };
3193    if !folder.is_dir() {
3194        return relocate_folder_err(
3195            want_json,
3196            StatusCode::UNPROCESSABLE_ENTITY,
3197            "Selected path is not a directory.",
3198            &run_id,
3199            &folder.display().to_string(),
3200            &redirect_url,
3201            &csp_nonce,
3202        );
3203    }
3204
3205    let json_candidates = find_result_files_by_ext(&folder, "json");
3206    if json_candidates.is_empty() {
3207        let msg = format!(
3208            "No result JSON files found in the selected folder.\nSearched: {}",
3209            folder.display()
3210        );
3211        return relocate_folder_err(
3212            want_json,
3213            StatusCode::UNPROCESSABLE_ENTITY,
3214            &msg,
3215            &run_id,
3216            &folder.display().to_string(),
3217            &redirect_url,
3218            &csp_nonce,
3219        );
3220    }
3221
3222    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3223        let msg = format!(
3224            "No matching scan found in the selected folder.\n\
3225             The JSON files present do not contain run ID: {run_id}\n\
3226             Searched: {}",
3227            folder.display()
3228        );
3229        return relocate_folder_err(
3230            want_json,
3231            StatusCode::UNPROCESSABLE_ENTITY,
3232            &msg,
3233            &run_id,
3234            &folder.display().to_string(),
3235            &redirect_url,
3236            &csp_nonce,
3237        );
3238    };
3239
3240    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3241    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3242    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3243
3244    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3245        redirect_url
3246    } else {
3247        "/compare-scans".to_string()
3248    };
3249    redirect_or_json_ok(want_json, &safe_redirect)
3250}
3251
3252fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3253    let mut out = Vec::new();
3254    collect_scan_files_by_ext(folder, ext, &mut out);
3255    if let Ok(rd) = fs::read_dir(folder) {
3256        for entry in rd.flatten() {
3257            let sub = entry.path();
3258            if sub.is_dir() {
3259                collect_scan_files_by_ext(&sub, ext, &mut out);
3260            }
3261        }
3262    }
3263    out
3264}
3265
3266fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3267    let Ok(rd) = fs::read_dir(dir) else { return };
3268    for entry in rd.flatten() {
3269        let p = entry.path();
3270        if p.is_file()
3271            && p.file_stem()
3272                .and_then(|n| n.to_str())
3273                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3274            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3275        {
3276            out.push(p);
3277        }
3278    }
3279}
3280
3281fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3282    candidates
3283        .iter()
3284        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3285        .cloned()
3286}
3287
3288async fn update_run_file_paths(
3289    state: &AppState,
3290    run_id: &str,
3291    json_path: PathBuf,
3292    html_path: Option<PathBuf>,
3293    pdf_path: Option<PathBuf>,
3294) {
3295    let mut reg = state.registry.lock().await;
3296    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3297        entry.json_path = Some(json_path);
3298        if let Some(hp) = html_path {
3299            entry.html_path = Some(hp);
3300        }
3301        if let Some(pp) = pdf_path {
3302            entry.pdf_path = Some(pp);
3303        }
3304    }
3305    let _ = reg.save(&state.registry_path);
3306}
3307
3308fn missing_scan_relocate_response(
3309    message: &str,
3310    run_id: &str,
3311    folder_hint: &str,
3312    redirect_url: &str,
3313    server_mode: bool,
3314    csp_nonce: &str,
3315) -> axum::response::Response {
3316    let html = RelocateScanTemplate {
3317        message: message.to_string(),
3318        run_id: run_id.to_string(),
3319        folder_hint: folder_hint.to_string(),
3320        redirect_url: redirect_url.to_string(),
3321        server_mode,
3322        csp_nonce: csp_nonce.to_owned(),
3323        version: env!("CARGO_PKG_VERSION"),
3324    }
3325    .render()
3326    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3327    (StatusCode::NOT_FOUND, Html(html)).into_response()
3328}
3329
3330// ── Watched-directory helpers ─────────────────────────────────────────────────
3331
3332/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3333fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3334    let mut candidates = Vec::new();
3335    if let Some(j) = find_result_json_in_dir(folder) {
3336        candidates.push(j);
3337    }
3338    if let Ok(dir_entries) = fs::read_dir(folder) {
3339        for entry in dir_entries.flatten() {
3340            let sub = entry.path();
3341            if sub.is_dir() {
3342                if let Some(j) = find_result_json_in_dir(&sub) {
3343                    candidates.push(j);
3344                }
3345            }
3346        }
3347    }
3348    candidates
3349}
3350
3351fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3352    reg.entries.iter().any(|e| {
3353        let dir_match = e
3354            .json_path
3355            .as_ref()
3356            .and_then(|p| p.parent())
3357            .is_some_and(|p| p == parent)
3358            || e.html_path
3359                .as_ref()
3360                .and_then(|p| p.parent())
3361                .is_some_and(|p| p == parent);
3362        dir_match
3363            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3364                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3365    })
3366}
3367
3368fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3369    let parent = json_path.parent()?.to_path_buf();
3370    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3371        rd.flatten()
3372            .map(|e| e.path())
3373            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3374    });
3375    let run = read_json(&json_path).ok()?;
3376    let project_label = run.input_roots.first().map_or_else(
3377        || "Unknown Project".to_string(),
3378        |r| sanitize_project_label(r),
3379    );
3380    Some(RegistryEntry {
3381        run_id: run.tool.run_id.clone(),
3382        timestamp_utc: run.tool.timestamp_utc,
3383        project_label,
3384        input_roots: run.input_roots.clone(),
3385        json_path: Some(json_path),
3386        html_path,
3387        pdf_path: None,
3388        csv_path: None,
3389        xlsx_path: None,
3390        summary: ScanSummarySnapshot {
3391            files_analyzed: run.summary_totals.files_analyzed,
3392            files_skipped: run.summary_totals.files_skipped,
3393            total_physical_lines: run.summary_totals.total_physical_lines,
3394            code_lines: run.summary_totals.code_lines,
3395            comment_lines: run.summary_totals.comment_lines,
3396            blank_lines: run.summary_totals.blank_lines,
3397            functions: run.summary_totals.functions,
3398            classes: run.summary_totals.classes,
3399            variables: run.summary_totals.variables,
3400            imports: run.summary_totals.imports,
3401            test_count: run.summary_totals.test_count,
3402            coverage_lines_found: run.summary_totals.coverage_lines_found,
3403            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3404            coverage_functions_found: run.summary_totals.coverage_functions_found,
3405            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3406            coverage_branches_found: run.summary_totals.coverage_branches_found,
3407            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3408        },
3409        git_branch: run.git_branch.clone(),
3410        git_commit: run.git_commit_short.clone(),
3411        git_author: run.git_commit_author.clone(),
3412        git_tags: run.git_tags.clone(),
3413        git_nearest_tag: run.git_nearest_tag.clone(),
3414        git_commit_date: run.git_commit_date,
3415    })
3416}
3417
3418/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3419/// Returns the number of newly linked entries.
3420fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3421    let mut linked = 0usize;
3422    for json_path in collect_result_json_candidates(folder) {
3423        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3424            continue;
3425        };
3426        if is_dir_already_registered(reg, &parent) {
3427            continue;
3428        }
3429        let Some(entry) = build_registry_entry_from_json(json_path) else {
3430            continue;
3431        };
3432        reg.add_entry(entry);
3433        linked += 1;
3434    }
3435    linked
3436}
3437
3438/// Scan all watched directories (plus the default output root) into `reg`.
3439async fn auto_scan_watched_dirs(state: &AppState) {
3440    let dirs: Vec<PathBuf> = {
3441        let wd = state.watched_dirs.lock().await;
3442        wd.dirs.clone()
3443    };
3444    if dirs.is_empty() {
3445        return;
3446    }
3447    let mut reg = state.registry.lock().await;
3448    let mut total = 0usize;
3449    for dir in &dirs {
3450        if dir.is_dir() {
3451            total += scan_folder_into_registry(dir, &mut reg);
3452        }
3453    }
3454    if total > 0 {
3455        let _ = reg.save(&state.registry_path);
3456    }
3457}
3458
3459// ── Watched-dir route forms ───────────────────────────────────────────────────
3460
3461#[derive(Deserialize)]
3462struct WatchedDirForm {
3463    folder_path: String,
3464    #[serde(default = "default_redirect")]
3465    redirect_to: String,
3466}
3467
3468fn default_redirect() -> String {
3469    "/view-reports".to_string()
3470}
3471
3472#[derive(Deserialize)]
3473struct WatchedDirRefreshForm {
3474    #[serde(default = "default_redirect")]
3475    redirect_to: String,
3476}
3477
3478// ── Watched-dir helpers ───────────────────────────────────────────────────────
3479
3480/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3481fn safe_redirect(dest: &str) -> &str {
3482    if dest.starts_with('/') {
3483        dest
3484    } else {
3485        "/"
3486    }
3487}
3488
3489// ── Watched-dir handlers ──────────────────────────────────────────────────────
3490
3491async fn add_watched_dir_handler(
3492    State(state): State<AppState>,
3493    Form(form): Form<WatchedDirForm>,
3494) -> impl IntoResponse {
3495    if state.server_mode {
3496        return StatusCode::NOT_FOUND.into_response();
3497    }
3498    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3499        strip_unc_prefix(p)
3500    } else {
3501        let dest = format!(
3502            "{}?error=Folder+not+found+or+path+is+invalid.",
3503            safe_redirect(&form.redirect_to)
3504        );
3505        return axum::response::Redirect::to(&dest).into_response();
3506    };
3507    if !folder.is_dir() {
3508        let dest = format!(
3509            "{}?error=Selected+path+is+not+a+directory.",
3510            safe_redirect(&form.redirect_to)
3511        );
3512        return axum::response::Redirect::to(&dest).into_response();
3513    }
3514
3515    // Persist the watched directory.
3516    {
3517        let mut wd = state.watched_dirs.lock().await;
3518        wd.add(folder.clone());
3519        let _ = wd.save(&state.watched_dirs_path);
3520    }
3521
3522    // Immediately scan the folder and add any new reports.
3523    let linked = {
3524        let mut reg = state.registry.lock().await;
3525        let n = scan_folder_into_registry(&folder, &mut reg);
3526        if n > 0 {
3527            let _ = reg.save(&state.registry_path);
3528        }
3529        n
3530    };
3531
3532    let dest = if linked > 0 {
3533        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3534    } else {
3535        format!(
3536            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3537            safe_redirect(&form.redirect_to)
3538        )
3539    };
3540    axum::response::Redirect::to(&dest).into_response()
3541}
3542
3543async fn remove_watched_dir_handler(
3544    State(state): State<AppState>,
3545    Form(form): Form<WatchedDirForm>,
3546) -> impl IntoResponse {
3547    if state.server_mode {
3548        return StatusCode::NOT_FOUND.into_response();
3549    }
3550    let folder = PathBuf::from(&form.folder_path);
3551    {
3552        let mut wd = state.watched_dirs.lock().await;
3553        wd.remove(&folder);
3554        let _ = wd.save(&state.watched_dirs_path);
3555    }
3556    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3557}
3558
3559async fn refresh_watched_dirs_handler(
3560    State(state): State<AppState>,
3561    Form(form): Form<WatchedDirRefreshForm>,
3562) -> impl IntoResponse {
3563    if state.server_mode {
3564        return StatusCode::NOT_FOUND.into_response();
3565    }
3566    let dirs: Vec<PathBuf> = {
3567        let wd = state.watched_dirs.lock().await;
3568        wd.dirs.clone()
3569    };
3570    let mut total = 0usize;
3571    {
3572        let mut reg = state.registry.lock().await;
3573        for dir in &dirs {
3574            if dir.is_dir() {
3575                total += scan_folder_into_registry(dir, &mut reg);
3576            }
3577        }
3578        if total > 0 {
3579            let _ = reg.save(&state.registry_path);
3580        }
3581    }
3582    let dest = if total > 0 {
3583        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3584    } else {
3585        safe_redirect(&form.redirect_to).to_owned()
3586    };
3587    axum::response::Redirect::to(&dest).into_response()
3588}
3589
3590#[derive(Debug, Deserialize)]
3591struct OpenPathQuery {
3592    path: Option<String>,
3593}
3594
3595fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3596    let mut ancestor = std::path::Path::new(raw);
3597    loop {
3598        match ancestor.parent() {
3599            Some(p) => {
3600                ancestor = p;
3601                if ancestor.is_dir() {
3602                    break;
3603                }
3604            }
3605            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3606        }
3607    }
3608    Ok(ancestor.to_path_buf())
3609}
3610
3611async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3612    match tokio::fs::canonicalize(raw).await {
3613        Ok(canonical) if canonical.is_file() => canonical
3614            .parent()
3615            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3616                Ok(p.to_path_buf())
3617            }),
3618        Ok(canonical) if canonical.is_dir() => Ok(canonical),
3619        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3620        Err(_) => find_existing_ancestor(raw),
3621    }
3622}
3623
3624async fn open_path_handler(
3625    State(state): State<AppState>,
3626    Query(query): Query<OpenPathQuery>,
3627) -> impl IntoResponse {
3628    if state.server_mode {
3629        return Json(serde_json::json!({
3630            "server_mode_disabled": true,
3631            "message": "Opening a path in the file manager is only available in local desktop mode."
3632        }))
3633        .into_response();
3634    }
3635    // Skip the OS file-manager call in headless / CI environments.
3636    if std::env::var("SLOC_HEADLESS").is_ok() {
3637        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3638    }
3639    let raw = match query.path.as_deref() {
3640        Some(p) if !p.is_empty() => p,
3641        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3642    };
3643
3644    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
3645    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
3646    // so the file explorer still opens somewhere useful.
3647    let target = match resolve_open_target(raw).await {
3648        Ok(p) => p,
3649        Err((code, msg)) => return (code, msg).into_response(),
3650    };
3651
3652    #[cfg(target_os = "windows")]
3653    win_dialog_focus::open_folder_foreground(target);
3654    #[cfg(target_os = "macos")]
3655    let _ = std::process::Command::new("open")
3656        .arg(&target)
3657        .stdout(Stdio::null())
3658        .stderr(Stdio::null())
3659        .spawn();
3660    #[cfg(target_os = "linux")]
3661    {
3662        let folder_name = target
3663            .file_name()
3664            .and_then(|n| n.to_str())
3665            .map(str::to_owned);
3666        let _ = std::process::Command::new("xdg-open")
3667            .arg(&target)
3668            .stdout(Stdio::null())
3669            .stderr(Stdio::null())
3670            .spawn();
3671        // Best-effort: raise the file manager window once it appears.
3672        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
3673        // installed; failures are silently discarded.
3674        if let Some(name) = folder_name {
3675            std::thread::spawn(move || {
3676                std::thread::sleep(std::time::Duration::from_millis(800));
3677                let _ = std::process::Command::new("wmctrl")
3678                    .args(["-a", &name])
3679                    .stdout(Stdio::null())
3680                    .stderr(Stdio::null())
3681                    .spawn();
3682            });
3683        }
3684    }
3685
3686    Json(serde_json::json!({"ok": true})).into_response()
3687}
3688
3689async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3690    let (content_type, bytes): (&'static str, &'static [u8]) =
3691        match (folder.as_str(), file.as_str()) {
3692            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3693            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3694            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3695            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3696            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3697            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3698            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3699            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3700            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3701            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3702            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3703            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3704            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3705            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3706            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3707            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3708            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3709            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3710            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3711            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3712            _ => return StatusCode::NOT_FOUND.into_response(),
3713        };
3714    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3715}
3716
3717async fn preview_handler(
3718    State(state): State<AppState>,
3719    Query(query): Query<PreviewQuery>,
3720) -> impl IntoResponse {
3721    let raw_path = query
3722        .path
3723        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3724    let resolved = resolve_input_path(&raw_path);
3725
3726    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3727    // binary whose working directory is not the project root), return a clear message
3728    // instead of an opaque OS error from build_preview_html.
3729    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3730        return Html(
3731            r#"<div class="preview-error">Sample directory not available on this server.
3732            Enter a path to a project directory or upload files using Browse.</div>"#
3733                .to_string(),
3734        );
3735    }
3736
3737    if state.server_mode {
3738        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3739        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3740        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3741            let config = &state.base_config;
3742            if config.discovery.allowed_scan_roots.is_empty() {
3743                return Html(
3744                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3745                );
3746            }
3747            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3748                fs::canonicalize(root)
3749                    .ok()
3750                    .is_some_and(|r| canonical.starts_with(&r))
3751            });
3752            if !allowed {
3753                return Html(
3754                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3755                );
3756            }
3757        }
3758    }
3759
3760    let include_patterns = split_patterns(query.include_globs.as_deref());
3761    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3762
3763    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3764        Ok(html) => Html(html),
3765        Err(err) => Html(format!(
3766            r#"<div class="preview-error">Preview failed: {}</div>"#,
3767            escape_html(&err.to_string())
3768        )),
3769    }
3770}
3771
3772#[derive(Debug, Deserialize, Default)]
3773struct SuggestCoverageQuery {
3774    path: Option<String>,
3775}
3776
3777#[derive(Serialize)]
3778struct SuggestCoverageResponse {
3779    found: Option<String>,
3780    tool: Option<&'static str>,
3781    hint: Option<&'static str>,
3782}
3783
3784async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3785    const CANDIDATES: &[&str] = &[
3786        // LCOV — cargo-llvm-cov, gcov, lcov
3787        "coverage/lcov.info",
3788        "lcov.info",
3789        "target/llvm-cov/lcov.info",
3790        "target/coverage/lcov.info",
3791        "target/debug/coverage/lcov.info",
3792        "coverage/coverage.lcov",
3793        "build/coverage/lcov.info",
3794        "reports/lcov.info",
3795        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3796        "coverage.xml",
3797        "coverage/coverage.xml",
3798        "target/site/cobertura/coverage.xml",
3799        "build/reports/coverage/coverage.xml",
3800        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3801        "target/site/jacoco/jacoco.xml",
3802        "build/reports/jacoco/test/jacocoTestReport.xml",
3803        "build/reports/jacoco/jacocoTestReport.xml",
3804        "build/jacoco/jacoco.xml",
3805    ];
3806    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3807    let found = CANDIDATES
3808        .iter()
3809        .map(|rel| root.join(rel))
3810        .find(|p| p.is_file())
3811        .map(|p| display_path(&p));
3812
3813    let (tool, hint) = detect_coverage_tool(&root);
3814    Json(SuggestCoverageResponse { found, tool, hint })
3815}
3816
3817/// Inspect the project root for known build/package files and return the most likely coverage
3818/// tool name and the shell command needed to generate a coverage file.
3819fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3820    if root.join("Cargo.toml").is_file() {
3821        return (
3822            Some("cargo-llvm-cov"),
3823            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3824        );
3825    }
3826    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3827        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3828    }
3829    if root.join("pom.xml").is_file() {
3830        return (Some("jacoco"), Some("mvn test jacoco:report"));
3831    }
3832    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3833        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3834    }
3835    (None, None)
3836}
3837
3838/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3839#[allow(clippy::result_large_err)]
3840fn validate_server_scan_path(
3841    config: &sloc_config::AppConfig,
3842    resolved_path: &Path,
3843    csp_nonce: &str,
3844) -> Result<(), Response> {
3845    if config.discovery.allowed_scan_roots.is_empty() {
3846        let template = ErrorTemplate {
3847            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3848                      Set allowed_scan_roots in the server config to permit scanning."
3849                .to_string(),
3850            last_report_url: None,
3851            last_report_label: None,
3852            run_id: None,
3853            error_code: Some(403),
3854            csp_nonce: csp_nonce.to_owned(),
3855            version: env!("CARGO_PKG_VERSION"),
3856        };
3857        return Err((
3858            StatusCode::FORBIDDEN,
3859            Html(
3860                template
3861                    .render()
3862                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3863            ),
3864        )
3865            .into_response());
3866    }
3867    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3868    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3869        fs::canonicalize(root)
3870            .ok()
3871            .is_some_and(|r| canonical.starts_with(&r))
3872    });
3873    if !allowed {
3874        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3875            "Scan path not in allowed_scan_roots");
3876        let template = ErrorTemplate {
3877            message: "The requested path is not within an allowed scan directory.".to_string(),
3878            last_report_url: None,
3879            last_report_label: None,
3880            run_id: None,
3881            error_code: Some(403),
3882            csp_nonce: csp_nonce.to_owned(),
3883            version: env!("CARGO_PKG_VERSION"),
3884        };
3885        return Err((
3886            StatusCode::FORBIDDEN,
3887            Html(
3888                template
3889                    .render()
3890                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3891            ),
3892        )
3893            .into_response());
3894    }
3895    Ok(())
3896}
3897
3898/// Exclude the output directory from scanning so artifacts don't pollute counts.
3899fn apply_output_dir_exclusions(
3900    config: &mut sloc_config::AppConfig,
3901    project_path: &str,
3902    raw_output_dir: &str,
3903) {
3904    let project_root = resolve_input_path(project_path);
3905    let raw_out = raw_output_dir.trim();
3906    let resolved_out = if raw_out.is_empty() {
3907        project_root.join("sloc")
3908    } else if Path::new(raw_out).is_absolute() {
3909        PathBuf::from(raw_out)
3910    } else {
3911        workspace_root().join(raw_out)
3912    };
3913    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3914        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3915            let dir = first.to_string();
3916            if !config.discovery.excluded_directories.contains(&dir) {
3917                config.discovery.excluded_directories.push(dir);
3918            }
3919        }
3920    }
3921    if !config
3922        .discovery
3923        .excluded_directories
3924        .iter()
3925        .any(|d| d == "sloc")
3926    {
3927        config
3928            .discovery
3929            .excluded_directories
3930            .push("sloc".to_string());
3931    }
3932}
3933
3934/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3935const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3936    ScanSummarySnapshot {
3937        files_analyzed: run.summary_totals.files_analyzed,
3938        files_skipped: run.summary_totals.files_skipped,
3939        total_physical_lines: run.summary_totals.total_physical_lines,
3940        code_lines: run.summary_totals.code_lines,
3941        comment_lines: run.summary_totals.comment_lines,
3942        blank_lines: run.summary_totals.blank_lines,
3943        functions: run.summary_totals.functions,
3944        classes: run.summary_totals.classes,
3945        variables: run.summary_totals.variables,
3946        imports: run.summary_totals.imports,
3947        test_count: run.summary_totals.test_count,
3948        coverage_lines_found: run.summary_totals.coverage_lines_found,
3949        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3950        coverage_functions_found: run.summary_totals.coverage_functions_found,
3951        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3952        coverage_branches_found: run.summary_totals.coverage_branches_found,
3953        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3954    }
3955}
3956
3957/// Build the `RegistryEntry` for the just-completed scan run.
3958pub(crate) fn build_run_registry_entry(
3959    run: &AnalysisRun,
3960    run_id: &str,
3961    project_label: &str,
3962    artifacts: &RunArtifacts,
3963) -> RegistryEntry {
3964    RegistryEntry {
3965        run_id: run_id.to_owned(),
3966        timestamp_utc: run.tool.timestamp_utc,
3967        project_label: project_label.to_owned(),
3968        input_roots: run.input_roots.clone(),
3969        json_path: artifacts.json_path.clone(),
3970        html_path: artifacts.html_path.clone(),
3971        pdf_path: artifacts.pdf_path.clone(),
3972        csv_path: artifacts.csv_path.clone(),
3973        xlsx_path: artifacts.xlsx_path.clone(),
3974        summary: summary_snapshot_from_run(run),
3975        git_branch: run.git_branch.clone(),
3976        git_commit: run.git_commit_short.clone(),
3977        git_author: run.git_commit_author.clone(),
3978        git_tags: run.git_tags.clone(),
3979        git_nearest_tag: run.git_nearest_tag.clone(),
3980        git_commit_date: run.git_commit_date.clone(),
3981    }
3982}
3983
3984/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3985fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3986    if let Some(policy) = form.mixed_line_policy {
3987        config.analysis.mixed_line_policy = policy;
3988    }
3989    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3990    config.analysis.generated_file_detection =
3991        form.generated_file_detection.as_deref() != Some("disabled");
3992    config.analysis.minified_file_detection =
3993        form.minified_file_detection.as_deref() != Some("disabled");
3994    config.analysis.vendor_directory_detection =
3995        form.vendor_directory_detection.as_deref() != Some("disabled");
3996    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3997    if let Some(binary_behavior) = form.binary_file_behavior {
3998        config.analysis.binary_file_behavior = binary_behavior;
3999    }
4000    apply_report_opts(config, form);
4001    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4002    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4003    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4004    if let Some(policy) = form.continuation_line_policy {
4005        config.analysis.continuation_line_policy = policy;
4006    }
4007    if let Some(policy) = form.blank_in_block_comment_policy {
4008        config.analysis.blank_in_block_comment_policy = policy;
4009    }
4010    config.analysis.count_compiler_directives =
4011        form.count_compiler_directives.as_deref() != Some("disabled");
4012    apply_style_threshold(config, form);
4013    apply_coverage_path(config, form);
4014}
4015
4016fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4017    if let Some(report_title) = form.report_title.as_deref() {
4018        let trimmed = report_title.trim();
4019        if !trimmed.is_empty() {
4020            config.reporting.report_title = trimmed.to_string();
4021        }
4022    }
4023    if let Some(hf) = form.report_header_footer.as_deref() {
4024        let trimmed = hf.trim();
4025        config.reporting.report_header_footer = if trimmed.is_empty() {
4026            None
4027        } else {
4028            Some(trimmed.to_string())
4029        };
4030    }
4031}
4032
4033fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4034    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4035        if let Ok(t) = threshold_str.parse::<u16>() {
4036            if t == 80 || t == 100 || t == 120 {
4037                config.analysis.style_col_threshold = t;
4038            }
4039        }
4040    }
4041    if let Some(v) = form.style_analysis_enabled.as_deref() {
4042        config.analysis.style_analysis_enabled = v != "disabled";
4043    }
4044    if let Some(v) = form.style_score_threshold.as_deref() {
4045        if let Ok(t) = v.parse::<u8>() {
4046            config.analysis.style_score_threshold = t.min(100);
4047        }
4048    }
4049    if let Some(v) = form.style_lang_scope.as_deref() {
4050        let scope = v.trim();
4051        if scope == "c_family" || scope == "all" {
4052            config.analysis.style_lang_scope = scope.to_string();
4053        }
4054    }
4055}
4056
4057fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4058    if let Some(cov) = &form.coverage_file {
4059        let trimmed = cov.trim();
4060        if !trimmed.is_empty() {
4061            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4062        }
4063    }
4064}
4065
4066/// Fire-and-forget: generate the PDF in a background task if one is pending.
4067/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4068/// an error instead of spinning indefinitely.
4069fn spawn_pdf_background(
4070    pending_pdf: PendingPdf,
4071    run_id: String,
4072    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4073) {
4074    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4075        tokio::spawn(async move {
4076            let result = tokio::task::spawn_blocking(move || {
4077                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4078                if cleanup_src {
4079                    let _ = fs::remove_file(&pdf_src);
4080                }
4081                r
4082            })
4083            .await;
4084            let failed = match result {
4085                Ok(Ok(())) => false,
4086                Ok(Err(err)) => {
4087                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4088                    true
4089                }
4090                Err(err) => {
4091                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4092                    true
4093                }
4094            };
4095            if failed {
4096                let mut map = artifacts.lock().await;
4097                if let Some(entry) = map.get_mut(&run_id) {
4098                    entry.pdf_path = None;
4099                }
4100            }
4101        });
4102    }
4103}
4104
4105/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4106/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4107/// result page can show an error on the next visit instead of spinning indefinitely.
4108fn spawn_native_pdf_background(
4109    json_path: PathBuf,
4110    pdf_dest: PathBuf,
4111    run_id: String,
4112    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4113) {
4114    tokio::spawn(async move {
4115        let result = tokio::task::spawn_blocking(move || {
4116            let run = sloc_core::read_json(&json_path)?;
4117            write_pdf_from_run(&run, &pdf_dest)
4118        })
4119        .await;
4120        let failed = match result {
4121            Ok(Ok(())) => false,
4122            Ok(Err(err)) => {
4123                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4124                true
4125            }
4126            Err(err) => {
4127                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4128                true
4129            }
4130        };
4131        if failed {
4132            let mut map = artifacts.lock().await;
4133            if let Some(entry) = map.get_mut(&run_id) {
4134                entry.pdf_path = None;
4135            }
4136        }
4137    });
4138}
4139
4140/// Sum the code lines added in this comparison (new + grown files).
4141fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4142    cmp.file_deltas
4143        .iter()
4144        .map(|f| match f.status {
4145            FileChangeStatus::Added => f.current_code,
4146            FileChangeStatus::Modified => f.code_delta.max(0),
4147            _ => 0,
4148        })
4149        .sum()
4150}
4151
4152/// Sum the code lines removed in this comparison (deleted + shrunk files).
4153fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4154    cmp.file_deltas
4155        .iter()
4156        .map(|f| match f.status {
4157            FileChangeStatus::Removed => f.baseline_code,
4158            FileChangeStatus::Modified => (-f.code_delta).max(0),
4159            _ => 0,
4160        })
4161        .sum()
4162}
4163
4164/// Sum the code lines present in both scans without any change (Unchanged files).
4165fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4166    cmp.file_deltas
4167        .iter()
4168        .filter(|f| f.status == FileChangeStatus::Unchanged)
4169        .map(|f| f.current_code)
4170        .sum()
4171}
4172
4173/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4174fn build_submodule_row(
4175    s: &sloc_core::SubmoduleSummary,
4176    run: &AnalysisRun,
4177    run_id: &str,
4178    run_dir: &Path,
4179) -> SubmoduleRow {
4180    let safe = sanitize_project_label(&s.name);
4181    let artifact_key = format!("sub_{safe}");
4182    let pdf_artifact_key = format!("sub_{safe}_pdf");
4183    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4184        let parent_path = run
4185            .input_roots
4186            .first()
4187            .map_or("", std::string::String::as_str);
4188        let sub_run = build_sub_run(run, s, parent_path);
4189        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4190        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4191            .ok()
4192            .and_then(|sub_html| {
4193                let sub_dir = run_dir.join("submodules");
4194                let _ = fs::create_dir_all(&sub_dir);
4195                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4196                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4197                    // Pre-generate the sub-report PDF using the programmatic renderer
4198                    // so "View PDF" never needs to spawn Chrome for submodules.
4199                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4200                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4201                    Some(format!("/runs/{artifact_key}/{run_id}"))
4202                } else {
4203                    None
4204                }
4205            })
4206    } else {
4207        None
4208    };
4209    SubmoduleRow {
4210        name: s.name.clone(),
4211        relative_path: s.relative_path.clone(),
4212        files_analyzed: s.files_analyzed,
4213        code_lines: s.code_lines,
4214        comment_lines: s.comment_lines,
4215        blank_lines: s.blank_lines,
4216        total_physical_lines: s.total_physical_lines,
4217        html_url,
4218    }
4219}
4220
4221// Immediately returns a wait page and runs the analysis in a background tokio task.
4222// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4223#[allow(clippy::similar_names)]
4224#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4225#[allow(clippy::too_many_lines)]
4226async fn analyze_handler(
4227    State(state): State<AppState>,
4228    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4229    Form(form): Form<AnalyzeForm>,
4230) -> impl IntoResponse {
4231    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4232        let template = ErrorTemplate {
4233            message: format!(
4234                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4235             Please wait a moment and try again."
4236            ),
4237            last_report_url: None,
4238            last_report_label: None,
4239            run_id: None,
4240            error_code: Some(503),
4241            csp_nonce: csp_nonce.clone(),
4242            version: env!("CARGO_PKG_VERSION"),
4243        };
4244        return (
4245            StatusCode::SERVICE_UNAVAILABLE,
4246            Html(
4247                template
4248                    .render()
4249                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4250            ),
4251        )
4252            .into_response();
4253    };
4254
4255    let mut config = state.base_config.clone();
4256
4257    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4258    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4259    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4260
4261    if !is_git_mode {
4262        let resolved_path = resolve_input_path(&form.path);
4263        if state.server_mode
4264            && !is_upload_tmp_path(&resolved_path)
4265            && !is_sample_path(&resolved_path)
4266        {
4267            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4268                return resp;
4269            }
4270        }
4271        config.discovery.root_paths = vec![resolved_path];
4272    }
4273
4274    apply_form_to_config(&mut config, &form);
4275    apply_output_dir_exclusions(
4276        &mut config,
4277        &form.path,
4278        form.output_dir.as_deref().unwrap_or(""),
4279    );
4280
4281    // Generate a wait_id now (before spawning) so the client can poll for status.
4282    let wait_id = uuid::Uuid::new_v4().to_string();
4283    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4284
4285    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4286    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4287    let task_cancel = Arc::clone(&cancel_token);
4288
4289    // Phase tracker: updated by run_analysis_task at key checkpoints.
4290    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4291    let task_phase = Arc::clone(&phase);
4292
4293    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4294    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4295    let task_files_done = Arc::clone(&files_done);
4296    let task_files_total = Arc::clone(&files_total);
4297
4298    // Register Running state before building the task struct so the semaphore permit
4299    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4300    {
4301        let mut runs = state.async_runs.lock().await;
4302        runs.insert(
4303            wait_id.clone(),
4304            AsyncRunState::Running {
4305                started_at: std::time::Instant::now(),
4306                cancel_token,
4307                phase,
4308                files_done,
4309                files_total,
4310            },
4311        );
4312    }
4313
4314    let task = AnalysisTask {
4315        sem_permit,
4316        state: state.clone(),
4317        wait_id: wait_id.clone(),
4318        config,
4319        cancel: task_cancel,
4320        phase: task_phase,
4321        files_done: task_files_done,
4322        files_total: task_files_total,
4323        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4324        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4325        project_path: form.path.clone(),
4326        // In server mode the client-supplied output_dir is ignored — artifacts are
4327        // always written under the server's configured output root so remote users
4328        // cannot direct writes to arbitrary filesystem paths.
4329        output_dir: if state.server_mode {
4330            None
4331        } else {
4332            form.output_dir.clone()
4333        },
4334        clones_dir: state.git_clones_dir.clone(),
4335        cocomo_mode: form
4336            .cocomo_mode
4337            .clone()
4338            .unwrap_or_else(|| "organic".to_string()),
4339        complexity_alert: form
4340            .complexity_alert
4341            .as_deref()
4342            .and_then(|s| s.parse::<u32>().ok())
4343            .unwrap_or(0),
4344        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4345    };
4346
4347    tokio::spawn(run_analysis_task(task));
4348
4349    let template = ScanWaitTemplate {
4350        version: env!("CARGO_PKG_VERSION"),
4351        wait_id_json,
4352        project_path: form.path.clone(),
4353        csp_nonce,
4354    };
4355    let html = template
4356        .render()
4357        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4358    let mut response = Html(html).into_response();
4359    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4360        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4361            response.headers_mut().insert(name, val);
4362        }
4363    }
4364    response
4365}
4366
4367struct AnalysisTask {
4368    sem_permit: tokio::sync::OwnedSemaphorePermit,
4369    state: AppState,
4370    wait_id: String,
4371    config: AppConfig,
4372    cancel: Arc<std::sync::atomic::AtomicBool>,
4373    phase: Arc<std::sync::Mutex<String>>,
4374    files_done: Arc<std::sync::atomic::AtomicUsize>,
4375    files_total: Arc<std::sync::atomic::AtomicUsize>,
4376    git_repo: Option<String>,
4377    git_ref: Option<String>,
4378    project_path: String,
4379    output_dir: Option<String>,
4380    clones_dir: PathBuf,
4381    cocomo_mode: String,
4382    complexity_alert: u32,
4383    exclude_duplicates: bool,
4384}
4385
4386#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4387async fn run_analysis_task(task: AnalysisTask) {
4388    let _permit = task.sem_permit;
4389
4390    let cancel_sb = Arc::clone(&task.cancel);
4391    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4392    let clones_dir_sb = task.clones_dir;
4393    // Save the upload staging path before config is moved into spawn_blocking.
4394    let upload_staging_root = task
4395        .config
4396        .discovery
4397        .root_paths
4398        .first()
4399        .filter(|p| is_upload_tmp_path(p))
4400        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4401        .map(PathBuf::from);
4402    let config_sb = task.config;
4403    let progress_sb = sloc_core::ProgressCounters {
4404        files_done: Arc::clone(&task.files_done),
4405        files_total: Arc::clone(&task.files_total),
4406    };
4407    if let Ok(mut p) = task.phase.lock() {
4408        *p = "Scanning files".to_string();
4409    }
4410    let analysis_result = tokio::task::spawn_blocking(move || {
4411        run_analysis_blocking(
4412            config_sb,
4413            git_repo_sb,
4414            git_ref_sb,
4415            clones_dir_sb,
4416            cancel_sb,
4417            Some(progress_sb),
4418        )
4419    })
4420    .await
4421    .map_err(|err| anyhow::anyhow!(err.to_string()))
4422    .and_then(|result| result);
4423
4424    if let Ok(mut p) = task.phase.lock() {
4425        *p = "Writing reports".to_string();
4426    }
4427
4428    // If cancelled while running, discard results and mark as cancelled.
4429    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4430        let mut runs = task.state.async_runs.lock().await;
4431        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4432        if matches!(
4433            runs.get(&task.wait_id),
4434            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4435        ) {
4436            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4437        }
4438        drop(runs);
4439        return;
4440    }
4441
4442    let run = match analysis_result {
4443        Ok(v) => v,
4444        Err(err) => {
4445            // Distinguish user-cancelled from real failure.
4446            if err.to_string().contains("analysis cancelled") {
4447                let mut runs = task.state.async_runs.lock().await;
4448                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4449                drop(runs);
4450                return;
4451            }
4452            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4453            let mut runs = task.state.async_runs.lock().await;
4454            runs.insert(
4455                task.wait_id.clone(),
4456                AsyncRunState::Failed {
4457                    message: "Analysis failed. Check that the path exists and is readable."
4458                        .to_string(),
4459                },
4460            );
4461            drop(runs);
4462            return;
4463        }
4464    };
4465
4466    let run_id = run.tool.run_id.clone();
4467    tracing::info!(event = "scan_complete", run_id = %run_id,
4468        path = %task.project_path, files = run.summary_totals.files_analyzed,
4469        "Analysis finished");
4470
4471    let prev_entry: Option<RegistryEntry> = {
4472        let reg = task.state.registry.lock().await;
4473        reg.entries_for_roots(&run.input_roots)
4474            .into_iter()
4475            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4476            .cloned()
4477    };
4478
4479    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4480        prev.json_path
4481            .as_ref()
4482            .and_then(|p| read_json(p).ok())
4483            .map(|prev_run| compute_delta(&prev_run, &run))
4484    });
4485    let prev_scan_count: usize = {
4486        let reg = task.state.registry.lock().await;
4487        reg.entries_for_roots(&run.input_roots)
4488            .iter()
4489            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4490            .count()
4491    };
4492
4493    // Build the HTML report now that delta is available, so the artifact
4494    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
4495    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4496        .as_ref()
4497        .zip(prev_entry.as_ref())
4498        .map(|(cmp, prev)| ReportDeltaContext {
4499            delta_code_added: sum_added_code_lines(cmp),
4500            delta_code_removed: sum_removed_code_lines(cmp),
4501            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4502            delta_files_added: cmp.files_added,
4503            delta_files_removed: cmp.files_removed,
4504            delta_files_modified: cmp.files_modified,
4505            delta_files_unchanged: cmp.files_unchanged,
4506            prev_code_lines: prev.summary.code_lines,
4507            prev_scan_count: prev_scan_count + 1,
4508            prev_scan_label: fmt_la_time(prev.timestamp_utc),
4509            prev_run_id: Some(prev.run_id.clone()),
4510            current_run_id: Some(run_id.clone()),
4511        });
4512    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4513        Ok(h) => h,
4514        Err(err) => {
4515            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4516            let mut runs = task.state.async_runs.lock().await;
4517            runs.insert(
4518                task.wait_id.clone(),
4519                AsyncRunState::Failed {
4520                    message: "Failed to render HTML report.".to_string(),
4521                },
4522            );
4523            drop(runs);
4524            return;
4525        }
4526    };
4527
4528    let output_root = resolve_output_root(task.output_dir.as_deref());
4529    let project_label = derive_project_label(
4530        task.git_repo.as_deref(),
4531        task.git_ref.as_deref(),
4532        &task.project_path,
4533    );
4534    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4535    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4536
4537    let result_context = RunResultContext {
4538        prev_entry: prev_entry.clone(),
4539        prev_scan_count,
4540        project_path: task.project_path.clone(),
4541        cocomo_mode: task.cocomo_mode.clone(),
4542        complexity_alert: task.complexity_alert,
4543        exclude_duplicates: task.exclude_duplicates,
4544    };
4545
4546    let artifact_result = persist_run_artifacts(
4547        &run,
4548        &report_html,
4549        &run_dir,
4550        &run.effective_configuration.reporting.report_title,
4551        &file_stem,
4552        result_context,
4553    );
4554
4555    let (artifacts, pending_pdf) = match artifact_result {
4556        Ok(v) => v,
4557        Err(err) => {
4558            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4559            let mut runs = task.state.async_runs.lock().await;
4560            runs.insert(
4561                task.wait_id.clone(),
4562                AsyncRunState::Failed {
4563                    message: "Failed to save report artifacts. Check available disk space."
4564                        .to_string(),
4565                },
4566            );
4567            drop(runs);
4568            return;
4569        }
4570    };
4571
4572    {
4573        let mut map = task.state.artifacts.lock().await;
4574        map.insert(run_id.clone(), artifacts.clone());
4575    }
4576
4577    {
4578        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4579        let mut reg = task.state.registry.lock().await;
4580        reg.add_entry(entry);
4581        let _ = reg.save(&task.state.registry_path);
4582    }
4583
4584    if let Some(ref cfg_path) = artifacts.scan_config_path {
4585        save_scan_config_json(
4586            cfg_path,
4587            &run,
4588            &task.project_path,
4589            task.output_dir.as_deref(),
4590        );
4591    }
4592
4593    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4594
4595    prom_runs_total().inc();
4596
4597    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
4598    let mut runs = task.state.async_runs.lock().await;
4599    runs.insert(
4600        task.wait_id.clone(),
4601        AsyncRunState::Complete {
4602            run_id: run_id.clone(),
4603        },
4604    );
4605    drop(runs);
4606
4607    // Remove the client-upload staging directory after a successful scan so
4608    // that uploaded project files don't accumulate in the OS temp directory.
4609    if let Some(staging) = upload_staging_root {
4610        let _ = tokio::fs::remove_dir_all(staging).await;
4611    }
4612
4613    let _ = scan_delta;
4614}
4615
4616fn save_scan_config_json(
4617    cfg_path: &std::path::Path,
4618    run: &sloc_core::AnalysisRun,
4619    project_path: &str,
4620    output_dir: Option<&str>,
4621) {
4622    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4623        .ok()
4624        .and_then(|v| v.as_str().map(String::from))
4625        .unwrap_or_else(|| "code_only".to_string());
4626    let behavior_str =
4627        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4628            .ok()
4629            .and_then(|v| v.as_str().map(String::from))
4630            .unwrap_or_else(|| "skip".to_string());
4631    let scan_cfg = ScanConfig {
4632        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4633        path: project_path.to_string(),
4634        include_globs: run
4635            .effective_configuration
4636            .discovery
4637            .include_globs
4638            .join("\n"),
4639        exclude_globs: run
4640            .effective_configuration
4641            .discovery
4642            .exclude_globs
4643            .join("\n"),
4644        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4645        mixed_line_policy: policy_str,
4646        python_docstrings_as_comments: run
4647            .effective_configuration
4648            .analysis
4649            .python_docstrings_as_comments,
4650        generated_file_detection: run
4651            .effective_configuration
4652            .analysis
4653            .generated_file_detection,
4654        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4655        vendor_directory_detection: run
4656            .effective_configuration
4657            .analysis
4658            .vendor_directory_detection,
4659        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4660        binary_file_behavior: behavior_str,
4661        output_dir: output_dir.unwrap_or("").to_string(),
4662        report_title: run.effective_configuration.reporting.report_title.clone(),
4663    };
4664    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4665        let _ = std::fs::write(cfg_path, json);
4666    }
4667}
4668
4669#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
4670fn run_analysis_blocking(
4671    mut config: AppConfig,
4672    git_repo: Option<String>,
4673    git_ref: Option<String>,
4674    clones_dir: PathBuf,
4675    cancel: Arc<std::sync::atomic::AtomicBool>,
4676    progress: Option<sloc_core::ProgressCounters>,
4677) -> Result<sloc_core::AnalysisRun> {
4678    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4679        let dest = git_clone_dest(&repo, &clones_dir);
4680        sloc_git::clone_or_fetch(&repo, &dest)?;
4681        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4682        sloc_git::create_worktree(&dest, &refname, &wt)?;
4683        config.discovery.root_paths = vec![wt.clone()];
4684        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4685        let _ = sloc_git::destroy_worktree(&dest, &wt);
4686        let mut run = run?;
4687        if run.git_branch.is_none() {
4688            run.git_branch = Some(refname);
4689        }
4690        return Ok(run);
4691    }
4692    analyze(&config, "serve", Some(&cancel), progress.as_ref())
4693}
4694
4695fn derive_project_label(
4696    git_repo: Option<&str>,
4697    git_ref: Option<&str>,
4698    fallback_path: &str,
4699) -> String {
4700    match (
4701        git_repo.filter(|s| !s.is_empty()),
4702        git_ref.filter(|s| !s.is_empty()),
4703    ) {
4704        (Some(repo), Some(refname)) => {
4705            let repo_name = repo
4706                .trim_end_matches('/')
4707                .trim_end_matches(".git")
4708                .rsplit('/')
4709                .next()
4710                .unwrap_or("repo");
4711            sanitize_project_label(&format!("{repo_name}_{refname}"))
4712        }
4713        _ => sanitize_project_label(fallback_path),
4714    }
4715}
4716
4717fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4718    let commit = commit_short.unwrap_or("").trim();
4719    if commit.is_empty() {
4720        project_label.to_string()
4721    } else {
4722        format!("{project_label}_{commit}")
4723    }
4724}
4725
4726// ── Async scan status + result handlers ──────────────────────────────────────
4727
4728#[derive(Serialize)]
4729#[serde(tag = "state", rename_all = "snake_case")]
4730enum AsyncRunStatusResponse {
4731    Running {
4732        elapsed_secs: u64,
4733        phase: String,
4734        files_done: u64,
4735        files_total: u64,
4736    },
4737    Complete {
4738        run_id: String,
4739    },
4740    Failed {
4741        message: String,
4742    },
4743    Cancelled,
4744}
4745
4746async fn async_run_status_handler(
4747    State(state): State<AppState>,
4748    AxumPath(wait_id): AxumPath<String>,
4749) -> Response {
4750    // wait_id comes from our own UUID generator; reject any structurally malformed value.
4751    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4752        return error::bad_request("invalid wait_id");
4753    }
4754    let run_state = {
4755        let runs = state.async_runs.lock().await;
4756        runs.get(&wait_id).cloned()
4757    };
4758    match run_state {
4759        None => error::not_found("run not found"),
4760        Some(AsyncRunState::Running {
4761            started_at,
4762            phase,
4763            files_done,
4764            files_total,
4765            ..
4766        }) => {
4767            // Treat runs older than 2 h as timed out (analysis should finish well under that).
4768            if started_at.elapsed() > std::time::Duration::from_hours(2) {
4769                let mut runs = state.async_runs.lock().await;
4770                runs.insert(
4771                    wait_id,
4772                    AsyncRunState::Failed {
4773                        message: "Analysis timed out after 2 hours.".to_string(),
4774                    },
4775                );
4776                drop(runs);
4777                return Json(AsyncRunStatusResponse::Failed {
4778                    message: "Analysis timed out after 2 hours.".to_string(),
4779                })
4780                .into_response();
4781            }
4782            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4783            Json(AsyncRunStatusResponse::Running {
4784                elapsed_secs: started_at.elapsed().as_secs(),
4785                phase: phase_str,
4786                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4787                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4788            })
4789            .into_response()
4790        }
4791        Some(AsyncRunState::Complete { run_id }) => {
4792            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4793        }
4794        Some(AsyncRunState::Failed { message }) => {
4795            Json(AsyncRunStatusResponse::Failed { message }).into_response()
4796        }
4797        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4798    }
4799}
4800
4801async fn cancel_run_handler(
4802    State(state): State<AppState>,
4803    AxumPath(wait_id): AxumPath<String>,
4804) -> Response {
4805    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4806        return error::bad_request("invalid wait_id");
4807    }
4808    let mut runs = state.async_runs.lock().await;
4809    let resp = match runs.get(&wait_id) {
4810        Some(AsyncRunState::Running { cancel_token, .. }) => {
4811            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4812            runs.insert(wait_id, AsyncRunState::Cancelled);
4813            StatusCode::OK.into_response()
4814        }
4815        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4816        _ => error::not_found("run not found"),
4817    };
4818    drop(runs);
4819    resp
4820}
4821
4822async fn async_run_result_handler(
4823    State(state): State<AppState>,
4824    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4825    AxumPath(run_id): AxumPath<String>,
4826) -> Response {
4827    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4828        return StatusCode::BAD_REQUEST.into_response();
4829    }
4830
4831    let artifacts = {
4832        let map = state.artifacts.lock().await;
4833        map.get(&run_id).cloned()
4834    };
4835    let artifacts = if let Some(a) = artifacts {
4836        a
4837    } else {
4838        let reg = state.registry.lock().await;
4839        if let Some(entry) = reg.find_by_run_id(&run_id) {
4840            recover_artifacts_from_registry(entry)
4841        } else {
4842            let html = ErrorTemplate {
4843                message: format!(
4844                    "Report not found. Run ID {} is not in the scan history.",
4845                    &run_id[..run_id.len().min(8)]
4846                ),
4847                last_report_url: Some("/view-reports".to_string()),
4848                last_report_label: Some("View Reports".to_string()),
4849                run_id: Some(run_id.clone()),
4850                error_code: Some(404),
4851                csp_nonce: csp_nonce.clone(),
4852                version: env!("CARGO_PKG_VERSION"),
4853            }
4854            .render()
4855            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4856            return (StatusCode::NOT_FOUND, Html(html)).into_response();
4857        }
4858    };
4859
4860    let json_path = if let Some(p) = &artifacts.json_path {
4861        p.clone()
4862    } else {
4863        let html = ErrorTemplate {
4864            message: "JSON result was not saved for this run.".to_string(),
4865            last_report_url: Some("/view-reports".to_string()),
4866            last_report_label: Some("View Reports".to_string()),
4867            run_id: Some(run_id.clone()),
4868            error_code: Some(404),
4869            csp_nonce: csp_nonce.clone(),
4870            version: env!("CARGO_PKG_VERSION"),
4871        }
4872        .render()
4873        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4874        return (StatusCode::NOT_FOUND, Html(html)).into_response();
4875    };
4876
4877    let Ok(run) = read_json(&json_path) else {
4878        let folder_hint = json_path
4879            .parent()
4880            .map(|p| p.display().to_string())
4881            .unwrap_or_default();
4882        let redirect_url = format!("/runs/result/{run_id}");
4883        return missing_scan_relocate_response(
4884            &format!(
4885                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4886                 deleted. Browse to the folder containing your scan output to reconnect it.",
4887                json_path.display()
4888            ),
4889            &run_id,
4890            &folder_hint,
4891            &redirect_url,
4892            state.server_mode,
4893            &csp_nonce,
4894        );
4895    };
4896
4897    let confluence_configured = {
4898        let store = state.confluence.lock().await;
4899        store.is_configured()
4900    };
4901
4902    render_result_page(
4903        &run,
4904        &artifacts,
4905        &run_id,
4906        &csp_nonce,
4907        confluence_configured,
4908        state.server_mode,
4909    )
4910}
4911
4912#[allow(clippy::too_many_lines)]
4913#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4914#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
4915fn render_result_page(
4916    run: &AnalysisRun,
4917    artifacts: &RunArtifacts,
4918    run_id: &str,
4919    csp_nonce: &str,
4920    confluence_configured: bool,
4921    server_mode: bool,
4922) -> Response {
4923    let ctx = &artifacts.result_context;
4924    let prev_entry = &ctx.prev_entry;
4925    let prev_scan_count = ctx.prev_scan_count;
4926    let project_path = &ctx.project_path;
4927
4928    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4929        prev.json_path
4930            .as_ref()
4931            .and_then(|p| read_json(p).ok())
4932            .map(|prev_run| compute_delta(&prev_run, run))
4933    });
4934
4935    let files_analyzed = run.per_file_records.len() as u64;
4936    let files_skipped = run.skipped_file_records.len() as u64;
4937    let physical_lines = run
4938        .totals_by_language
4939        .iter()
4940        .map(|r| r.total_physical_lines)
4941        .sum::<u64>();
4942    let code_lines = run
4943        .totals_by_language
4944        .iter()
4945        .map(|r| r.code_lines)
4946        .sum::<u64>();
4947    let comment_lines = run
4948        .totals_by_language
4949        .iter()
4950        .map(|r| r.comment_lines)
4951        .sum::<u64>();
4952    let blank_lines = run
4953        .totals_by_language
4954        .iter()
4955        .map(|r| r.blank_lines)
4956        .sum::<u64>();
4957    let mixed_lines = run
4958        .totals_by_language
4959        .iter()
4960        .map(|r| r.mixed_lines_separate)
4961        .sum::<u64>();
4962    let functions = run
4963        .totals_by_language
4964        .iter()
4965        .map(|r| r.functions)
4966        .sum::<u64>();
4967    let classes = run
4968        .totals_by_language
4969        .iter()
4970        .map(|r| r.classes)
4971        .sum::<u64>();
4972    let variables = run
4973        .totals_by_language
4974        .iter()
4975        .map(|r| r.variables)
4976        .sum::<u64>();
4977    let imports = run
4978        .totals_by_language
4979        .iter()
4980        .map(|r| r.imports)
4981        .sum::<u64>();
4982
4983    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4984    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4985    let prev_fs = prev_sum.map(|s| s.files_skipped);
4986    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4987    let prev_cl = prev_sum.map(|s| s.code_lines);
4988    let prev_cml = prev_sum.map(|s| s.comment_lines);
4989    let prev_bl = prev_sum.map(|s| s.blank_lines);
4990    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4991    let prev_fa_str = fmt_prev(prev_fa);
4992    let prev_fs_str = fmt_prev(prev_fs);
4993    let prev_pl_str = fmt_prev(prev_pl);
4994    let prev_cl_str = fmt_prev(prev_cl);
4995    let prev_cml_str = fmt_prev(prev_cml);
4996    let prev_bl_str = fmt_prev(prev_bl);
4997    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4998    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4999    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
5000    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
5001    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
5002    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
5003    let delta_fa_class = delta_fa_class.to_string();
5004    let delta_fs_class = delta_fs_class.to_string();
5005    let delta_pl_class = delta_pl_class.to_string();
5006    let delta_cl_class = delta_cl_class.to_string();
5007    let delta_cml_class = delta_cml_class.to_string();
5008    let delta_bl_class = delta_bl_class.to_string();
5009
5010    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
5011    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
5012    let (delta_lines_net_str, delta_lines_net_class) =
5013        match (delta_lines_added, delta_lines_removed) {
5014            (Some(a), Some(r)) => {
5015                let net = a - r;
5016                (fmt_delta(net), delta_class(net).to_string())
5017            }
5018            _ => ("—".to_string(), "na".to_string()),
5019        };
5020
5021    let run_dir = artifacts.output_dir.clone();
5022    let git_branch = run.git_branch.clone();
5023    let git_commit = run.git_commit_short.clone();
5024    let git_commit_long = run.git_commit_long.clone();
5025    let git_author = run.git_commit_author.clone();
5026    let git_commit_url = run
5027        .git_remote_url
5028        .as_deref()
5029        .zip(run.git_commit_long.as_deref())
5030        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
5031    let git_branch_url = run
5032        .git_remote_url
5033        .as_deref()
5034        .zip(run.git_branch.as_deref())
5035        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
5036    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
5037        format!(
5038            "{} / {}",
5039            run.environment.initiator_username, run.environment.initiator_hostname
5040        )
5041    });
5042    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5043    let os_display = format!(
5044        "{} / {}",
5045        run.environment.operating_system, run.environment.architecture
5046    );
5047    let test_count = run.summary_totals.test_count;
5048
5049    // ── New metrics ──────────────────────────────────────────────────────────
5050    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5051    let lsloc = run.summary_totals.lsloc;
5052    let uloc = run.uloc;
5053    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5054    let duplicate_group_count = run.duplicate_groups.len();
5055
5056    // Re-compute COCOMO with the mode selected in the scan wizard.
5057    let ctx = &artifacts.result_context;
5058    let (
5059        has_cocomo,
5060        cocomo_effort_str,
5061        cocomo_duration_str,
5062        cocomo_staff_str,
5063        cocomo_ksloc_str,
5064        cocomo_mode_label,
5065        cocomo_mode_tooltip,
5066    ) = {
5067        let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5068        let mode_str = ctx.cocomo_mode.as_str();
5069        let (a, b, c, d, label, tooltip): (f64, f64, f64, f64, &str, &str) = match mode_str {
5070            "semi_detached" => (3.0, 1.12, 2.5, 0.35, "Semi-detached",
5071                "Semi-detached: A mixed team with varying experience tackling a project with \
5072                 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5073                 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."),
5074            "embedded" => (3.6, 1.20, 2.5, 0.32, "Embedded",
5075                "Embedded: Tight hardware, software, or operational constraints requiring \
5076                 significant innovation and deep integration work. Typical for real-time control \
5077                 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."),
5078            _ => (2.4, 1.05, 2.5, 0.38, "Organic",
5079                "Organic: A small team working on a well-understood project in a familiar \
5080                 environment with minimal external constraints. Suited for internal tools, \
5081                 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."),
5082        };
5083        let effort = a * ksloc.powf(b);
5084        let duration = c * effort.powf(d);
5085        let staff = if duration > 0.0 {
5086            effort / duration
5087        } else {
5088            0.0
5089        };
5090        if run.summary_totals.code_lines > 0 {
5091            (
5092                true,
5093                format!("{:.2}", (effort * 100.0).round() / 100.0),
5094                format!("{:.2}", (duration * 100.0).round() / 100.0),
5095                format!("{:.2}", (staff * 100.0).round() / 100.0),
5096                format!("{:.2}", (ksloc * 100.0).round() / 100.0),
5097                label.to_string(),
5098                tooltip.to_string(),
5099            )
5100        } else {
5101            (
5102                false,
5103                String::new(),
5104                String::new(),
5105                String::new(),
5106                String::new(),
5107                label.to_string(),
5108                tooltip.to_string(),
5109            )
5110        }
5111    };
5112    let complexity_alert = ctx.complexity_alert;
5113
5114    let template = ResultTemplate {
5115        version: env!("CARGO_PKG_VERSION"),
5116        report_title: run.effective_configuration.reporting.report_title.clone(),
5117        project_path: project_path.clone(),
5118        output_dir: display_path(&artifacts.output_dir),
5119        run_id: run_id.to_owned(),
5120        run_id_short: run_id
5121            .split('-')
5122            .next_back()
5123            .unwrap_or(run_id)
5124            .chars()
5125            .take(7)
5126            .collect(),
5127        files_analyzed,
5128        files_skipped,
5129        physical_lines,
5130        code_lines,
5131        comment_lines,
5132        blank_lines,
5133        mixed_lines,
5134        functions,
5135        classes,
5136        variables,
5137        imports,
5138        html_url: artifacts
5139            .html_path
5140            .as_ref()
5141            .map(|_| format!("/runs/html/{run_id}")),
5142        pdf_url: artifacts
5143            .pdf_path
5144            .as_ref()
5145            .map(|_| format!("/runs/pdf/{run_id}")),
5146        json_url: artifacts
5147            .json_path
5148            .as_ref()
5149            .map(|_| format!("/runs/json/{run_id}")),
5150        html_download_url: artifacts
5151            .html_path
5152            .as_ref()
5153            .map(|_| format!("/runs/html/{run_id}?download=1")),
5154        pdf_download_url: artifacts
5155            .pdf_path
5156            .as_ref()
5157            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5158        json_download_url: artifacts
5159            .json_path
5160            .as_ref()
5161            .map(|_| format!("/runs/json/{run_id}?download=1")),
5162        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5163        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5164        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5165        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5166        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5167        prev_fa_str,
5168        prev_fs_str,
5169        prev_pl_str,
5170        prev_cl_str,
5171        prev_cml_str,
5172        prev_bl_str,
5173        delta_fa_str,
5174        delta_fa_class,
5175        delta_fs_str,
5176        delta_fs_class,
5177        delta_pl_str,
5178        delta_pl_class,
5179        delta_cl_str,
5180        delta_cl_class,
5181        delta_cml_str,
5182        delta_cml_class,
5183        delta_bl_str,
5184        delta_bl_class,
5185        delta_lines_added,
5186        delta_lines_removed,
5187        delta_lines_net_str,
5188        delta_lines_net_class,
5189        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
5190        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
5191        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
5192        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
5193        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
5194            d.file_deltas
5195                .iter()
5196                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5197                .map(|f| {
5198                    #[allow(clippy::cast_sign_loss)]
5199                    let n = f.current_code as u64;
5200                    n
5201                })
5202                .sum()
5203        }),
5204        git_branch,
5205        git_branch_url,
5206        git_commit,
5207        git_commit_long,
5208        git_author,
5209        git_commit_url,
5210        scan_performed_by,
5211        scan_time_display,
5212        os_display,
5213        test_count,
5214        current_scan_number: prev_scan_count + 1,
5215        prev_scan_count,
5216        submodule_rows: run
5217            .submodule_summaries
5218            .iter()
5219            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
5220            .collect(),
5221        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
5222        scan_config_url: format!("/runs/scan-config/{run_id}"),
5223        lang_chart_json: {
5224            let mut langs: Vec<&sloc_core::LanguageSummary> =
5225                run.totals_by_language.iter().collect();
5226            langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5227            let entries: Vec<String> = langs
5228                .into_iter()
5229                .take(12)
5230                .map(|l| {
5231                    let name = l
5232                        .language
5233                        .display_name()
5234                        .replace('\\', "\\\\")
5235                        .replace('"', "\\\"");
5236                    format!(
5237                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5238                        name,
5239                        l.code_lines,
5240                        l.comment_lines,
5241                        l.blank_lines,
5242                        l.total_physical_lines,
5243                        l.functions,
5244                        l.classes,
5245                        l.variables,
5246                        l.imports,
5247                        l.files,
5248                    )
5249                })
5250                .collect();
5251            format!("[{}]", entries.join(","))
5252        },
5253        scatter_chart_json: {
5254            let entries: Vec<String> = run
5255                .totals_by_language
5256                .iter()
5257                .map(|l| {
5258                    let name = l
5259                        .language
5260                        .display_name()
5261                        .replace('\\', "\\\\")
5262                        .replace('"', "\\\"");
5263                    format!(
5264                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5265                        name, l.files, l.code_lines, l.total_physical_lines,
5266                    )
5267                })
5268                .collect();
5269            format!("[{}]", entries.join(","))
5270        },
5271        semantic_chart_json: {
5272            let entries: Vec<String> = run
5273                .totals_by_language
5274                .iter()
5275                .filter(|l| {
5276                    l.functions > 0
5277                        || l.classes > 0
5278                        || l.variables > 0
5279                        || l.imports > 0
5280                        || l.test_count > 0
5281                })
5282                .map(|l| {
5283                    let name = l
5284                        .language
5285                        .display_name()
5286                        .replace('\\', "\\\\")
5287                        .replace('"', "\\\"");
5288                    format!(
5289                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5290                        name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5291                    )
5292                })
5293                .collect();
5294            format!("[{}]", entries.join(","))
5295        },
5296        submodule_chart_json: {
5297            let entries: Vec<String> = run
5298                .submodule_summaries
5299                .iter()
5300                .map(|s| {
5301                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5302                    format!(
5303                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5304                        name,
5305                        s.code_lines,
5306                        s.comment_lines,
5307                        s.blank_lines,
5308                        s.total_physical_lines,
5309                        s.files_analyzed,
5310                    )
5311                })
5312                .collect();
5313            format!("[{}]", entries.join(","))
5314        },
5315        has_submodule_data: !run.submodule_summaries.is_empty(),
5316        has_semantic_data: run
5317            .totals_by_language
5318            .iter()
5319            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5320        csp_nonce: csp_nonce.to_owned(),
5321        confluence_configured,
5322        server_mode,
5323        report_header_footer: run
5324            .effective_configuration
5325            .reporting
5326            .report_header_footer
5327            .clone(),
5328        is_offline: false,
5329        cyclomatic_complexity,
5330        lsloc,
5331        uloc,
5332        dryness_pct_str,
5333        duplicate_group_count,
5334        has_cocomo,
5335        cocomo_effort_str,
5336        cocomo_duration_str,
5337        cocomo_staff_str,
5338        cocomo_ksloc_str,
5339        cocomo_mode_label,
5340        cocomo_mode_tooltip,
5341        complexity_alert,
5342    };
5343
5344    Html(
5345        template
5346            .render()
5347            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5348    )
5349    .into_response()
5350}
5351
5352fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5353    let slug: String = report_title
5354        .chars()
5355        .map(|c| {
5356            if c.is_alphanumeric() || c == '-' {
5357                c.to_ascii_lowercase()
5358            } else {
5359                '_'
5360            }
5361        })
5362        .collect::<String>()
5363        .split('_')
5364        .filter(|s| !s.is_empty())
5365        .collect::<Vec<_>>()
5366        .join("_");
5367
5368    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5369
5370    if slug.is_empty() {
5371        format!("report_{short_id}.pdf")
5372    } else {
5373        format!("{slug}_{short_id}.pdf")
5374    }
5375}
5376
5377#[derive(Serialize)]
5378struct PdfStatusResponse {
5379    ready: bool,
5380}
5381
5382/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
5383/// Clients poll this to update the button state without page reloads.
5384async fn pdf_status_handler(
5385    State(state): State<AppState>,
5386    AxumPath(run_id): AxumPath<String>,
5387) -> Response {
5388    let pdf_path = {
5389        let registry = state.artifacts.lock().await;
5390        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5391    };
5392    let pdf_path = if pdf_path.is_some() {
5393        pdf_path
5394    } else {
5395        let reg = state.registry.lock().await;
5396        reg.find_by_run_id(&run_id)
5397            .map(recover_artifacts_from_registry)
5398            .and_then(|a| a.pdf_path)
5399    };
5400    let ready = pdf_path.is_some_and(|p| p.exists());
5401    Json(PdfStatusResponse { ready }).into_response()
5402}
5403
5404/// GET /`api/runs/:run_id/bundle`
5405///
5406/// Streams a gzip-compressed tar archive containing every artifact in the run's
5407/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
5408/// is built in memory so it never touches a temp file.
5409async fn download_bundle_handler(
5410    State(state): State<AppState>,
5411    AxumPath(run_id): AxumPath<String>,
5412) -> Response {
5413    // Resolve output directory from in-memory cache or persisted registry.
5414    let output_dir = {
5415        let cache = state.artifacts.lock().await;
5416        cache.get(&run_id).map(|a| a.output_dir.clone())
5417    };
5418    let output_dir = if let Some(d) = output_dir {
5419        d
5420    } else {
5421        let reg = state.registry.lock().await;
5422        match reg.find_by_run_id(&run_id) {
5423            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5424            None => {
5425                return (
5426                    StatusCode::NOT_FOUND,
5427                    Json(serde_json::json!({"error": "Run not found"})),
5428                )
5429                    .into_response();
5430            }
5431        }
5432    };
5433
5434    if !output_dir.exists() {
5435        return (
5436            StatusCode::NOT_FOUND,
5437            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5438        )
5439            .into_response();
5440    }
5441
5442    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
5443    let run_id_clone = run_id.clone();
5444    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5445        use flate2::{write::GzEncoder, Compression};
5446        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5447        {
5448            let mut tar = tar::Builder::new(&mut enc);
5449            tar.follow_symlinks(false);
5450            // Append every regular file in the output directory, skipping
5451            // sub-directories (the output dir is always flat).
5452            if let Ok(entries) = std::fs::read_dir(&output_dir) {
5453                for entry in entries.filter_map(Result::ok) {
5454                    let p = entry.path();
5455                    if p.is_file() {
5456                        let name = p.file_name().unwrap_or_default().to_string_lossy();
5457                        let archive_path = format!("{run_id_clone}/{name}");
5458                        tar.append_path_with_name(&p, &archive_path)?;
5459                    }
5460                }
5461            }
5462            tar.finish()?;
5463        }
5464        Ok(enc.finish()?)
5465    })
5466    .await;
5467
5468    match archive_result {
5469        Ok(Ok(bytes)) => {
5470            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5471            axum::response::Response::builder()
5472                .status(StatusCode::OK)
5473                .header("Content-Type", "application/gzip")
5474                .header(
5475                    "Content-Disposition",
5476                    format!("attachment; filename=\"{filename}\""),
5477                )
5478                .header("Content-Length", bytes.len().to_string())
5479                .body(axum::body::Body::from(bytes))
5480                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5481        }
5482        Ok(Err(e)) => (
5483            StatusCode::INTERNAL_SERVER_ERROR,
5484            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5485        )
5486            .into_response(),
5487        Err(e) => (
5488            StatusCode::INTERNAL_SERVER_ERROR,
5489            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5490        )
5491            .into_response(),
5492    }
5493}
5494
5495/// DELETE /`api/runs/:run_id`
5496///
5497/// Removes all on-disk artifacts for the run and purges the run from the
5498/// in-memory cache and the persisted registry. Returns 204 on success.
5499async fn delete_run_handler(
5500    State(state): State<AppState>,
5501    AxumPath(run_id): AxumPath<String>,
5502) -> Response {
5503    // Resolve output directory.
5504    let output_dir = {
5505        let mut cache = state.artifacts.lock().await;
5506        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5507        cache.remove(&run_id);
5508        dir
5509    };
5510    let output_dir = if let Some(d) = output_dir {
5511        d
5512    } else {
5513        let reg = state.registry.lock().await;
5514        reg.find_by_run_id(&run_id)
5515            .map(|e| recover_artifacts_from_registry(e).output_dir)
5516            .unwrap_or_default()
5517    };
5518
5519    // Remove from persisted registry.
5520    {
5521        let mut reg = state.registry.lock().await;
5522        reg.entries.retain(|e| e.run_id != run_id);
5523        let _ = reg.save(&state.registry_path);
5524    }
5525
5526    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
5527    // a prior delete may have already removed the directory.
5528    if output_dir.exists() {
5529        match tokio::fs::remove_dir_all(&output_dir).await {
5530            Ok(()) => {}
5531            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
5532            Err(e) => {
5533                return (
5534                    StatusCode::INTERNAL_SERVER_ERROR,
5535                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5536                )
5537                    .into_response();
5538            }
5539        }
5540    }
5541
5542    StatusCode::NO_CONTENT.into_response()
5543}
5544
5545/// POST /api/runs/cleanup
5546///
5547/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
5548/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
5549async fn cleanup_runs_handler(
5550    State(state): State<AppState>,
5551    Json(body): Json<serde_json::Value>,
5552) -> Response {
5553    let days = body
5554        .get("older_than_days")
5555        .and_then(serde_json::Value::as_u64)
5556        .unwrap_or(30)
5557        .max(1);
5558
5559    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5560
5561    // Collect expired entries from the registry.
5562    let expired: Vec<(String, PathBuf)> = {
5563        let reg = state.registry.lock().await;
5564        reg.entries
5565            .iter()
5566            .filter(|e| e.timestamp_utc < cutoff)
5567            .map(|e| {
5568                let arts = recover_artifacts_from_registry(e);
5569                (e.run_id.clone(), arts.output_dir)
5570            })
5571            .collect()
5572    };
5573
5574    let mut deleted = 0usize;
5575    for (run_id, output_dir) in &expired {
5576        // Remove from in-memory cache.
5577        state.artifacts.lock().await.remove(run_id);
5578        // Delete on-disk artifacts (non-fatal if already gone).
5579        if output_dir.exists() {
5580            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5581                eprintln!(
5582                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5583                    output_dir.display()
5584                );
5585                continue;
5586            }
5587        }
5588        deleted += 1;
5589    }
5590
5591    // Purge expired run IDs from the registry in one pass.
5592    let expired_ids: std::collections::HashSet<&str> =
5593        expired.iter().map(|(id, _)| id.as_str()).collect();
5594    {
5595        let mut reg = state.registry.lock().await;
5596        reg.entries
5597            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5598        let _ = reg.save(&state.registry_path);
5599    }
5600
5601    Json(serde_json::json!({ "deleted": deleted })).into_response()
5602}
5603
5604/// Spawns the background auto-cleanup task. Returns a handle so the caller can
5605/// abort it when the policy is updated or disabled.
5606fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5607    tokio::spawn(async move {
5608        loop {
5609            let interval_secs = {
5610                let store = state.cleanup_policy.lock().await;
5611                match &store.policy {
5612                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5613                    _ => break,
5614                }
5615            };
5616            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5617            let n = run_auto_cleanup(&state).await;
5618            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5619        }
5620    })
5621}
5622
5623fn collect_runs_to_delete(
5624    reg: &ScanRegistry,
5625    max_age_days: Option<u32>,
5626    max_run_count: Option<u32>,
5627) -> std::collections::HashSet<String> {
5628    let mut to_delete = std::collections::HashSet::new();
5629    if let Some(days) = max_age_days {
5630        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5631        for e in &reg.entries {
5632            if e.timestamp_utc < cutoff {
5633                to_delete.insert(e.run_id.clone());
5634            }
5635        }
5636    }
5637    if let Some(max_count) = max_run_count {
5638        // entries are sorted newest-first; skip the ones we keep
5639        for e in reg.entries.iter().skip(max_count as usize) {
5640            to_delete.insert(e.run_id.clone());
5641        }
5642    }
5643    to_delete
5644}
5645
5646async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5647    let output_dir = {
5648        let mut cache = state.artifacts.lock().await;
5649        let d = cache.get(run_id).map(|a| a.output_dir.clone());
5650        cache.remove(run_id);
5651        d
5652    };
5653    let output_dir = if let Some(d) = output_dir {
5654        d
5655    } else {
5656        let reg = state.registry.lock().await;
5657        reg.find_by_run_id(run_id)
5658            .map(|e| recover_artifacts_from_registry(e).output_dir)
5659            .unwrap_or_default()
5660    };
5661    if output_dir.exists() {
5662        let _ = tokio::fs::remove_dir_all(&output_dir).await;
5663    }
5664}
5665
5666/// Core cleanup logic shared by the background task and the "Run Now" handler.
5667/// Applies both the age limit and the count limit, then updates `last_run_at`.
5668/// Returns the number of runs deleted.
5669async fn run_auto_cleanup(state: &AppState) -> u32 {
5670    let (max_age_days, max_run_count) = {
5671        let store = state.cleanup_policy.lock().await;
5672        match &store.policy {
5673            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5674            _ => return 0,
5675        }
5676    };
5677
5678    let to_delete = {
5679        let reg = state.registry.lock().await;
5680        collect_runs_to_delete(&reg, max_age_days, max_run_count)
5681    };
5682
5683    for run_id in &to_delete {
5684        delete_run_artifacts(state, run_id).await;
5685    }
5686
5687    // Purge from registry.
5688    if !to_delete.is_empty() {
5689        let mut reg = state.registry.lock().await;
5690        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5691        let _ = reg.save(&state.registry_path);
5692    }
5693
5694    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5695    {
5696        let mut store = state.cleanup_policy.lock().await;
5697        store.last_run_at = Some(chrono::Utc::now());
5698        store.last_run_deleted = Some(deleted);
5699        let _ = store.save(&state.cleanup_policy_path);
5700    }
5701    deleted
5702}
5703
5704// ── Auto-cleanup policy API ───────────────────────────────────────────────────
5705
5706/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
5707async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5708    let store = state.cleanup_policy.lock().await;
5709    Json(serde_json::json!({
5710        "policy": store.policy,
5711        "last_run_at": store.last_run_at,
5712        "last_run_deleted": store.last_run_deleted,
5713    }))
5714    .into_response()
5715}
5716
5717/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
5718async fn api_save_cleanup_policy(
5719    State(state): State<AppState>,
5720    Json(body): Json<CleanupPolicy>,
5721) -> Response {
5722    // Abort any running task so the new interval takes effect immediately.
5723    {
5724        let mut handle = state.cleanup_task_handle.lock().await;
5725        if let Some(h) = handle.take() {
5726            h.abort();
5727        }
5728    }
5729    {
5730        let mut store = state.cleanup_policy.lock().await;
5731        store.policy = Some(body.clone());
5732        if let Err(e) = store.save(&state.cleanup_policy_path) {
5733            return (
5734                StatusCode::INTERNAL_SERVER_ERROR,
5735                Json(serde_json::json!({"error": e.to_string()})),
5736            )
5737                .into_response();
5738        }
5739    }
5740    if body.enabled {
5741        let handle = spawn_cleanup_policy_task(state.clone());
5742        *state.cleanup_task_handle.lock().await = Some(handle);
5743    }
5744    StatusCode::NO_CONTENT.into_response()
5745}
5746
5747/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
5748async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5749    let deleted = run_auto_cleanup(&state).await;
5750    Json(serde_json::json!({ "deleted": deleted })).into_response()
5751}
5752
5753/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
5754async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5755    {
5756        let mut handle = state.cleanup_task_handle.lock().await;
5757        if let Some(h) = handle.take() {
5758            h.abort();
5759        }
5760    }
5761    {
5762        let mut store = state.cleanup_policy.lock().await;
5763        store.policy = None;
5764        let _ = store.save(&state.cleanup_policy_path);
5765    }
5766    StatusCode::NO_CONTENT.into_response()
5767}
5768
5769/// Serve the HTML artifact for a run — view or download.
5770/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
5771/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
5772/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
5773/// Only called for browser views; downloads keep the self-contained inline version.
5774fn swap_inline_chart_js_for_static(html: String) -> String {
5775    let Some(head_end) = html.find("</head>") else {
5776        return html;
5777    };
5778    let Some(script_start) = html[..head_end].rfind("<script") else {
5779        return html;
5780    };
5781    let Some(close_offset) = html[script_start..].find("</script>") else {
5782        return html;
5783    };
5784    let block_end = script_start + close_offset + "</script>".len();
5785    format!(
5786        "{}<script src=\"/static/chart-report.js\"></script>{}",
5787        &html[..script_start],
5788        &html[block_end..]
5789    )
5790}
5791
5792/// current-request Content-Security-Policy nonce check.
5793fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5794    // Find the first nonce value that was baked in at render time.
5795    let Some(start) = html.find("nonce=\"") else {
5796        // Reports generated before nonce support was added have bare <style> and <script>
5797        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
5798        // the inline blocks — without it the browser blocks all CSS and JS.
5799        return html
5800            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5801            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5802    };
5803    let value_start = start + 7; // len(r#"nonce=""#) == 7
5804    let Some(end_offset) = html[value_start..].find('"') else {
5805        return html.to_owned();
5806    };
5807    let old_nonce = &html[value_start..value_start + end_offset];
5808    html.replace(
5809        &format!("nonce=\"{old_nonce}\""),
5810        &format!("nonce=\"{new_nonce}\""),
5811    )
5812}
5813
5814fn serve_html_artifact(
5815    path: &Path,
5816    wants_download: bool,
5817    csp_nonce: &str,
5818    run_id: &str,
5819    server_mode: bool,
5820) -> Response {
5821    match fs::read_to_string(path) {
5822        Ok(raw) => {
5823            // Patch the saved nonce so inline styles/scripts pass CSP.
5824            let content = patch_html_nonce(&raw, csp_nonce);
5825            if wants_download {
5826                // Keep the self-contained inline version for downloads (opened as file://).
5827                (
5828                    [
5829                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5830                        (
5831                            header::CONTENT_DISPOSITION,
5832                            "attachment; filename=report.html",
5833                        ),
5834                    ],
5835                    content,
5836                )
5837                    .into_response()
5838            } else {
5839                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
5840                // browser caches it after the first view; the HTML response also shrinks.
5841                Html(swap_inline_chart_js_for_static(content)).into_response()
5842            }
5843        }
5844        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5845            let filename = path.file_name().map_or_else(
5846                || "report.html".to_string(),
5847                |n| n.to_string_lossy().into_owned(),
5848            );
5849            let html = LocateFileTemplate {
5850                run_id: run_id.to_owned(),
5851                artifact_type: "html".to_string(),
5852                expected_filename: filename,
5853                server_mode,
5854                csp_nonce: csp_nonce.to_owned(),
5855                version: env!("CARGO_PKG_VERSION"),
5856            }
5857            .render()
5858            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5859            (StatusCode::NOT_FOUND, Html(html)).into_response()
5860        }
5861        Err(err) => {
5862            let filename = path.file_name().map_or_else(
5863                || "report.html".to_string(),
5864                |n| n.to_string_lossy().into_owned(),
5865            );
5866            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5867            let html = ErrorTemplate {
5868                message: msg,
5869                last_report_url: Some("/view-reports".to_string()),
5870                last_report_label: Some("View Reports".to_string()),
5871                run_id: None,
5872                error_code: Some(404),
5873                csp_nonce: csp_nonce.to_owned(),
5874                version: env!("CARGO_PKG_VERSION"),
5875            }
5876            .render()
5877            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5878            (StatusCode::NOT_FOUND, Html(html)).into_response()
5879        }
5880    }
5881}
5882
5883/// Serve the PDF artifact for a run — inline or download.
5884fn serve_pdf_artifact(
5885    path: &Path,
5886    report_title: &str,
5887    run_id: &str,
5888    wants_download: bool,
5889    csp_nonce: &str,
5890) -> Response {
5891    match fs::read(path) {
5892        Ok(bytes) => {
5893            let filename = build_pdf_filename(report_title, run_id);
5894            let disposition = if wants_download {
5895                format!("attachment; filename=\"{filename}\"")
5896            } else {
5897                format!("inline; filename=\"{filename}\"")
5898            };
5899            (
5900                [
5901                    (header::CONTENT_TYPE, "application/pdf".to_string()),
5902                    (header::CONTENT_DISPOSITION, disposition),
5903                ],
5904                bytes,
5905            )
5906                .into_response()
5907        }
5908        Err(err) => {
5909            let filename = path.file_name().map_or_else(
5910                || "report.pdf".to_string(),
5911                |n| n.to_string_lossy().into_owned(),
5912            );
5913            let msg = format!(
5914                "PDF report '{filename}' could not be read.\n\n\
5915                 Error: {err}\n\n\
5916                 If you moved or renamed the output folder, the stored path is now stale. \
5917                 Use 'Open PDF folder' from the results page to browse the output directory."
5918            );
5919            let html = ErrorTemplate {
5920                message: msg,
5921                last_report_url: Some("/view-reports".to_string()),
5922                last_report_label: Some("View Reports".to_string()),
5923                run_id: Some(run_id.to_owned()),
5924                error_code: Some(404),
5925                csp_nonce: csp_nonce.to_owned(),
5926                version: env!("CARGO_PKG_VERSION"),
5927            }
5928            .render()
5929            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5930            (StatusCode::NOT_FOUND, Html(html)).into_response()
5931        }
5932    }
5933}
5934
5935/// Serve the JSON artifact for a run — view or download.
5936fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5937    match fs::read(path) {
5938        Ok(bytes) => {
5939            if wants_download {
5940                (
5941                    [
5942                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5943                        (
5944                            header::CONTENT_DISPOSITION,
5945                            "attachment; filename=result.json",
5946                        ),
5947                    ],
5948                    bytes,
5949                )
5950                    .into_response()
5951            } else {
5952                (
5953                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5954                    bytes,
5955                )
5956                    .into_response()
5957            }
5958        }
5959        Err(err) => {
5960            let filename = path.file_name().map_or_else(
5961                || "result.json".to_string(),
5962                |n| n.to_string_lossy().into_owned(),
5963            );
5964            let msg = format!(
5965                "JSON result '{filename}' could not be read.\n\n\
5966                 Error: {err}\n\n\
5967                 If you moved or renamed the output folder, the stored path is now stale. \
5968                 Use 'Open JSON folder' from the results page to browse the output directory."
5969            );
5970            let html = ErrorTemplate {
5971                message: msg,
5972                last_report_url: Some("/view-reports".to_string()),
5973                last_report_label: Some("View Reports".to_string()),
5974                run_id: None,
5975                error_code: Some(404),
5976                csp_nonce: csp_nonce.to_owned(),
5977                version: env!("CARGO_PKG_VERSION"),
5978            }
5979            .render()
5980            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5981            (StatusCode::NOT_FOUND, Html(html)).into_response()
5982        }
5983    }
5984}
5985
5986/// Recover a `RunArtifacts` from the persisted registry for a run ID.
5987fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5988    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
5989    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
5990    let output_dir = entry
5991        .html_path
5992        .as_ref()
5993        .or(entry.json_path.as_ref())
5994        .or(entry.pdf_path.as_ref())
5995        .or(entry.csv_path.as_ref())
5996        .or(entry.xlsx_path.as_ref())
5997        .and_then(|p| {
5998            let parent = p.parent()?;
5999            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6000            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
6001            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6002                parent.parent().map(PathBuf::from)
6003            } else {
6004                Some(parent.to_path_buf())
6005            }
6006        })
6007        .unwrap_or_default();
6008    // Recover pdf_path: use the persisted one, or look for report.pdf
6009    // adjacent to html/json if only the old entries lack it.
6010    let pdf_path = entry.pdf_path.clone().or_else(|| {
6011        let candidate = output_dir.join("report.pdf");
6012        candidate.exists().then_some(candidate)
6013    });
6014    // csv_path / xlsx_path: persisted paths take precedence; fall back to
6015    // scanning the run directory for files matching the expected patterns so
6016    // that runs created before this feature still surface their artifacts.
6017    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6018        // Check excel/ subfolder (new layout) then root (old layout).
6019        for dir in &[output_dir.join("excel"), output_dir.clone()] {
6020            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6021                entries
6022                    .filter_map(std::result::Result::ok)
6023                    .find(|e| {
6024                        let n = e.file_name();
6025                        let n = n.to_string_lossy();
6026                        n.starts_with("report_") && n.ends_with(ext)
6027                    })
6028                    .map(|e| e.path())
6029            }) {
6030                return Some(p);
6031            }
6032        }
6033        None
6034    };
6035
6036    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6037    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6038    RunArtifacts {
6039        output_dir: output_dir.clone(),
6040        html_path: entry.html_path.clone(),
6041        pdf_path,
6042        json_path: entry.json_path.clone(),
6043        csv_path,
6044        xlsx_path,
6045        scan_config_path: find_scan_config_in_dir(&output_dir),
6046        report_title: entry.project_label.clone(),
6047        result_context: RunResultContext::default(),
6048    }
6049}
6050
6051#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6052async fn resolve_artifact_set(
6053    state: &AppState,
6054    run_id: &str,
6055    csp_nonce: &str,
6056) -> Result<RunArtifacts, Response> {
6057    let cached = state.artifacts.lock().await.get(run_id).cloned();
6058    if let Some(a) = cached {
6059        return Ok(a);
6060    }
6061    let reg = state.registry.lock().await;
6062    if let Some(entry) = reg.find_by_run_id(run_id) {
6063        return Ok(recover_artifacts_from_registry(entry));
6064    }
6065    drop(reg);
6066    let short_id = &run_id[..run_id.len().min(8)];
6067    let hint = if matches!(
6068        run_id,
6069        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6070    ) {
6071        format!(
6072            " The URL format appears to be reversed — \
6073             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6074             Use the View Reports page to navigate to your scan."
6075        )
6076    } else {
6077        " The report may have been deleted or the report directory moved. \
6078         Use View Reports to browse your scan history."
6079            .to_string()
6080    };
6081    let error_html = ErrorTemplate {
6082        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6083        last_report_url: Some("/view-reports".to_string()),
6084        last_report_label: Some("View Reports".to_string()),
6085        run_id: None,
6086        error_code: Some(404),
6087        csp_nonce: csp_nonce.to_owned(),
6088        version: env!("CARGO_PKG_VERSION"),
6089    }
6090    .render()
6091    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6092    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6093}
6094
6095/// Return the path to a run's PDF, queuing background generation when it is missing.
6096///
6097/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6098/// Returns `Err(response)` when there is no JSON source to regenerate from.
6099async fn resolve_or_queue_pdf(
6100    state: &AppState,
6101    pdf_path: Option<PathBuf>,
6102    json_path: Option<PathBuf>,
6103    output_dir: PathBuf,
6104    run_id: &str,
6105    report_title: &str,
6106    csp_nonce: &str,
6107) -> Result<PathBuf, Response> {
6108    if let Some(p) = pdf_path {
6109        return Ok(p);
6110    }
6111    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6112        let msg = "PDF report was not generated for this run. \
6113                   Re-run the analysis with PDF output enabled."
6114            .to_string();
6115        let html = ErrorTemplate {
6116            message: msg,
6117            last_report_url: Some(format!("/runs/html/{run_id}")),
6118            last_report_label: Some("View HTML Report".to_string()),
6119            run_id: Some(run_id.to_string()),
6120            error_code: Some(404),
6121            csp_nonce: csp_nonce.to_string(),
6122            version: env!("CARGO_PKG_VERSION"),
6123        }
6124        .render()
6125        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6126        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6127    };
6128    let pdf_filename = build_pdf_filename(report_title, run_id);
6129    let pdf_dest = output_dir.join(&pdf_filename);
6130    if !pdf_dest.exists() {
6131        // Record the pending path so concurrent requests show the spinner.
6132        {
6133            let mut map = state.artifacts.lock().await;
6134            if let Some(entry) = map.get_mut(run_id) {
6135                entry.pdf_path = Some(pdf_dest.clone());
6136            }
6137        }
6138        {
6139            let mut reg = state.registry.lock().await;
6140            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6141                e.pdf_path = Some(pdf_dest.clone());
6142            }
6143            let _ = reg.save(&state.registry_path);
6144        }
6145        spawn_native_pdf_background(
6146            json_src,
6147            pdf_dest.clone(),
6148            run_id.to_string(),
6149            state.artifacts.clone(),
6150        );
6151    }
6152    Ok(pdf_dest)
6153}
6154
6155/// Self-refreshing "please wait" page shown while the background PDF task is still running.
6156fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6157    let html = format!(
6158                    "<!doctype html><html lang=\"en\"><head>\
6159                     <meta charset=utf-8>\
6160                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6161                     <meta http-equiv=\"refresh\" content=\"5\">\
6162                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
6163                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6164                     <style nonce=\"{csp_nonce}\">\
6165                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6166                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6167                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6168                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6169                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6170                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6171                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6172                     background:var(--bg);color:var(--text);}}\
6173                     .top-nav{{position:sticky;top:0;z-index:30;\
6174                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6175                     border-bottom:1px solid rgba(255,255,255,0.12);\
6176                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6177                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6178                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
6179                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6180                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6181                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6182                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6183                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6184                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6185                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6186                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6187                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6188                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6189                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6190                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6191                     justify-content:center;min-height:38px;border-radius:999px;\
6192                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6193                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6194                     .theme-toggle .icon-sun{{display:none;}}\
6195                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6196                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6197                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6198                     display:flex;align-items:center;justify-content:center;\
6199                     min-height:calc(100vh - 56px);}}\
6200                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6201                     .panel{{background:var(--surface);border:1px solid var(--line);\
6202                     border-radius:var(--radius);box-shadow:var(--shadow);\
6203                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6204                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
6205                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
6206                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6207                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6208                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6209                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6210                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6211                     min-height:42px;padding:0 20px;border-radius:14px;\
6212                     border:1px solid var(--line-strong);text-decoration:none;\
6213                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6214                     .back-link:hover{{background:var(--line);}}\
6215                     </style></head>\
6216                     <body>\
6217                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6218                       <a class=\"brand\" href=\"/\">\
6219                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6220                         <div class=\"brand-copy\">\
6221                           <div class=\"brand-title\">OxideSLOC</div>\
6222                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6223                         </div>\
6224                       </a>\
6225                       <div class=\"nav-right\">\
6226                         <a class=\"nav-pill\" href=\"/\">Home</a>\
6227                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6228                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6229                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6230                           <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>\
6231                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6232                           <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>\
6233                         </button>\
6234                       </div>\
6235                     </div></div>\
6236                     <div class=\"page\"><div class=\"panel\">\
6237                       <div class=\"spin-ring\"></div>\
6238                       <h1>Generating PDF\u{2026}</h1>\
6239                       <p>The PDF is being generated from the scan results.<br>\
6240                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
6241                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6242                     </div></div>\
6243                     <script nonce=\"{csp_nonce}\">\
6244                     (function(){{\
6245                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6246                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
6247                       var t=document.getElementById(\"theme-toggle\");\
6248                       if(t)t.addEventListener(\"click\",function(){{\
6249                         var d=b.classList.toggle(\"dark-theme\");\
6250                         localStorage.setItem(k,d?\"dark\":\"light\");\
6251                       }});\
6252                     }})();\
6253                     </script>\
6254                     </body></html>"
6255    );
6256    Html(html).into_response()
6257}
6258
6259/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
6260fn render_error_artifact_html(
6261    message: String,
6262    last_report_url: Option<String>,
6263    last_report_label: Option<String>,
6264    run_id: Option<String>,
6265    error_code: Option<u16>,
6266    csp_nonce: &str,
6267) -> String {
6268    ErrorTemplate {
6269        message,
6270        last_report_url,
6271        last_report_label,
6272        run_id,
6273        error_code,
6274        csp_nonce: csp_nonce.to_owned(),
6275        version: env!("CARGO_PKG_VERSION"),
6276    }
6277    .render()
6278    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
6279}
6280
6281/// Read a file and serve it as an attachment download.
6282fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
6283    fs::read(path).map_or_else(
6284        |_| StatusCode::NOT_FOUND.into_response(),
6285        |bytes| {
6286            let filename = path.file_name().map_or_else(
6287                || fallback_filename.to_string(),
6288                |n| n.to_string_lossy().into_owned(),
6289            );
6290            (
6291                [
6292                    (header::CONTENT_TYPE, content_type.to_string()),
6293                    (
6294                        header::CONTENT_DISPOSITION,
6295                        format!("attachment; filename=\"{filename}\""),
6296                    ),
6297                ],
6298                bytes,
6299            )
6300                .into_response()
6301        },
6302    )
6303}
6304
6305fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6306    let Some(path) = csv_path else {
6307        let html = render_error_artifact_html(
6308            "CSV report was not generated for this run, or was not recorded in \
6309             the scan registry."
6310                .to_string(),
6311            Some(format!("/runs/html/{run_id}")),
6312            Some("View HTML Report".to_string()),
6313            Some(run_id.to_string()),
6314            Some(404),
6315            csp_nonce,
6316        );
6317        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6318    };
6319    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6320}
6321
6322fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6323    let Some(path) = xlsx_path else {
6324        let html = render_error_artifact_html(
6325            "Excel report was not generated for this run, or was not recorded in \
6326             the scan registry."
6327                .to_string(),
6328            Some(format!("/runs/html/{run_id}")),
6329            Some("View HTML Report".to_string()),
6330            Some(run_id.to_string()),
6331            Some(404),
6332            csp_nonce,
6333        );
6334        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6335    };
6336    serve_binary_download(
6337        &path,
6338        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6339        "report.xlsx",
6340    )
6341}
6342
6343fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6344    let path = artifact_set
6345        .scan_config_path
6346        .as_deref()
6347        .map(std::path::Path::to_path_buf)
6348        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6349        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6350    fs::read(&path).map_or_else(
6351        |_| StatusCode::NOT_FOUND.into_response(),
6352        |bytes| {
6353            (
6354                [
6355                    (
6356                        header::CONTENT_TYPE,
6357                        "application/json; charset=utf-8".to_string(),
6358                    ),
6359                    (
6360                        header::CONTENT_DISPOSITION,
6361                        "attachment; filename=\"scan-config.json\"".to_string(),
6362                    ),
6363                ],
6364                bytes,
6365            )
6366                .into_response()
6367        },
6368    )
6369}
6370
6371/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
6372/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
6373/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
6374/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
6375async fn serve_submodule_pdf_arm(
6376    artifact: &str,
6377    artifact_set: RunArtifacts,
6378    wants_download: bool,
6379    run_id: &str,
6380    csp_nonce: &str,
6381) -> Response {
6382    // "sub_benchmark_pdf" → base = "sub_benchmark"
6383    let base = artifact.trim_end_matches("_pdf");
6384    let sub_dir = artifact_set.output_dir.join("submodules");
6385    let pdf_path = sub_dir.join(format!("{base}.pdf"));
6386
6387    if !pdf_path.exists() {
6388        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
6389        let derived_safe = base.trim_start_matches("sub_");
6390        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6391            let parent_run = read_json(jp).ok()?;
6392            let sub = parent_run
6393                .submodule_summaries
6394                .iter()
6395                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6396                .clone();
6397            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6398            Some((parent_run, sub, parent_path))
6399        });
6400
6401        if let Some((parent_run, sub, parent_path)) = rebuilt {
6402            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6403            let pp = pdf_path.clone();
6404            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6405        }
6406    }
6407
6408    if !pdf_path.exists() {
6409        let html = render_error_artifact_html(
6410            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6411             enabled."
6412                .to_string(),
6413            Some("/view-reports".to_string()),
6414            Some("View Reports".to_string()),
6415            Some(run_id.to_string()),
6416            Some(404),
6417            csp_nonce,
6418        );
6419        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6420    }
6421
6422    serve_pdf_artifact(
6423        &pdf_path,
6424        &artifact_set.report_title,
6425        run_id,
6426        wants_download,
6427        csp_nonce,
6428    )
6429}
6430
6431fn serve_submodule_arm(
6432    artifact: &str,
6433    artifact_set: &RunArtifacts,
6434    wants_download: bool,
6435    csp_nonce: &str,
6436    run_id: &str,
6437    server_mode: bool,
6438) -> Response {
6439    if artifact.len() > 128
6440        || !artifact
6441            .chars()
6442            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6443    {
6444        return StatusCode::BAD_REQUEST.into_response();
6445    }
6446    let filename = format!("{artifact}.html");
6447    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
6448    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6449    let path = if new_layout.exists() {
6450        new_layout
6451    } else {
6452        artifact_set.output_dir.join(&filename)
6453    };
6454    if !path.exists() {
6455        let html = render_error_artifact_html(
6456            format!(
6457                "Sub-report '{artifact}' was not found in the run directory.\n\
6458                 Re-run the analysis with 'Detect and separate git submodules' \
6459                 and HTML output enabled."
6460            ),
6461            Some("/view-reports".to_string()),
6462            Some("View Reports".to_string()),
6463            Some(run_id.to_string()),
6464            Some(404),
6465            csp_nonce,
6466        );
6467        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6468    }
6469    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6470}
6471
6472async fn serve_pdf_arm(
6473    state: &AppState,
6474    artifact_set: RunArtifacts,
6475    wants_download: bool,
6476    run_id: &str,
6477    csp_nonce: &str,
6478) -> Response {
6479    let report_title = artifact_set.report_title.clone();
6480    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6481    let stale_html_name = artifact_set
6482        .html_path
6483        .as_deref()
6484        .and_then(|p| p.file_name())
6485        .map(|n| n.to_string_lossy().into_owned());
6486    let path = match resolve_or_queue_pdf(
6487        state,
6488        artifact_set.pdf_path,
6489        artifact_set.json_path.clone(),
6490        artifact_set.output_dir.clone(),
6491        run_id,
6492        &report_title,
6493        csp_nonce,
6494    )
6495    .await
6496    {
6497        Ok(p) => p,
6498        Err(r) => return r,
6499    };
6500    if !path.exists() {
6501        // Distinguish a stale registry path (folder moved) from an in-progress
6502        // background generation. Only show the locate page when the PDF was
6503        // already recorded in the registry but the file is now missing.
6504        if had_pdf_in_registry {
6505            if let Some(expected_filename) = stale_html_name {
6506                let html = LocateFileTemplate {
6507                    run_id: run_id.to_string(),
6508                    artifact_type: "pdf".to_string(),
6509                    expected_filename,
6510                    server_mode: state.server_mode,
6511                    csp_nonce: csp_nonce.to_string(),
6512                    version: env!("CARGO_PKG_VERSION"),
6513                }
6514                .render()
6515                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6516                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6517            }
6518        }
6519        return pdf_generating_response(run_id, csp_nonce);
6520    }
6521    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6522}
6523
6524async fn artifact_handler(
6525    State(state): State<AppState>,
6526    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6527    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6528    Query(query): Query<ArtifactQuery>,
6529) -> Response {
6530    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6531        Ok(a) => a,
6532        Err(r) => return r,
6533    };
6534
6535    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6536
6537    match artifact.as_str() {
6538        "html" => {
6539            let Some(path) = artifact_set.html_path else {
6540                return StatusCode::NOT_FOUND.into_response();
6541            };
6542            serve_html_artifact(
6543                &path,
6544                wants_download,
6545                &csp_nonce,
6546                &run_id,
6547                state.server_mode,
6548            )
6549        }
6550        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6551        "json" => {
6552            let Some(path) = artifact_set.json_path else {
6553                let html = render_error_artifact_html(
6554                    "JSON result was not generated for this run, or was not recorded in \
6555                     the scan registry. Re-run the analysis with JSON output enabled."
6556                        .to_string(),
6557                    Some("/view-reports".to_string()),
6558                    Some("View Reports".to_string()),
6559                    Some(run_id.clone()),
6560                    Some(404),
6561                    &csp_nonce,
6562                );
6563                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6564            };
6565            serve_json_artifact(&path, wants_download, &csp_nonce)
6566        }
6567        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6568        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6569        "scan-config" => serve_scan_config_arm(&artifact_set),
6570        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6571            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6572                .await
6573        }
6574        _ if artifact.starts_with("sub_") => serve_submodule_arm(
6575            &artifact,
6576            &artifact_set,
6577            wants_download,
6578            &csp_nonce,
6579            &run_id,
6580            state.server_mode,
6581        ),
6582        _ => StatusCode::NOT_FOUND.into_response(),
6583    }
6584}
6585
6586// ── History ───────────────────────────────────────────────────────────────────
6587
6588struct SubmoduleLinkRow {
6589    name: String,
6590    url: String,
6591}
6592
6593struct HistoryEntryRow {
6594    run_id: String,
6595    run_id_short: String,
6596    timestamp: String,
6597    timestamp_utc_ms: i64,
6598    project_label: String,
6599    project_path: String,
6600    files_analyzed: u64,
6601    files_skipped: u64,
6602    code_lines: u64,
6603    comment_lines: u64,
6604    blank_lines: u64,
6605    git_branch: String,
6606    git_commit: String,
6607    has_html: bool,
6608    has_json: bool,
6609    has_pdf: bool,
6610    submodule_links: Vec<SubmoduleLinkRow>,
6611    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
6612    submodule_names_csv: String,
6613}
6614
6615/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
6616fn nth_weekday_of_month(
6617    year: i32,
6618    month: u32,
6619    weekday: chrono::Weekday,
6620    n: u32,
6621) -> chrono::NaiveDate {
6622    use chrono::Datelike;
6623    let mut count = 0u32;
6624    let mut day = 1u32;
6625    loop {
6626        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6627        if d.weekday() == weekday {
6628            count += 1;
6629            if count == n {
6630                return d;
6631            }
6632        }
6633        day += 1;
6634    }
6635}
6636
6637/// Returns true if `dt` falls within US Pacific Daylight Time.
6638/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
6639/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
6640fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6641    use chrono::{Datelike, TimeZone};
6642    let year = dt.year();
6643    let dst_start = chrono::Utc.from_utc_datetime(
6644        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6645            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6646    );
6647    let dst_end = chrono::Utc.from_utc_datetime(
6648        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6649            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6650    );
6651    dt >= dst_start && dt < dst_end
6652}
6653
6654fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6655    if is_pacific_dst(dt) {
6656        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6657            .format("%Y-%m-%d %H:%M PDT")
6658            .to_string()
6659    } else {
6660        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6661            .format("%Y-%m-%d %H:%M PST")
6662            .to_string()
6663    }
6664}
6665
6666/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
6667fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6668    let (offset, tz) = if is_pacific_dst(dt) {
6669        (
6670            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6671            "PDT",
6672        )
6673    } else {
6674        (
6675            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6676            "PST",
6677        )
6678    };
6679    format!(
6680        "{} {tz}",
6681        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6682    )
6683}
6684
6685fn fmt_git_date(iso: &str) -> Option<String> {
6686    chrono::DateTime::parse_from_rfc3339(iso)
6687        .ok()
6688        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6689}
6690
6691fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6692    reg.entries
6693        .iter()
6694        .map(|e| {
6695            let submodule_links = {
6696                let mut links: Vec<SubmoduleLinkRow> = vec![];
6697                let sub_dir = e
6698                    .html_path
6699                    .as_ref()
6700                    .and_then(|p| p.parent())
6701                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6702                if let Some(dir) = sub_dir {
6703                    if let Ok(rd) = std::fs::read_dir(dir) {
6704                        for entry_res in rd.flatten() {
6705                            let fname = entry_res.file_name();
6706                            let fname_str = fname.to_string_lossy();
6707                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6708                                let stem = &fname_str[..fname_str.len() - 5];
6709                                let display = stem[4..].replace('-', " ");
6710                                links.push(SubmoduleLinkRow {
6711                                    name: display,
6712                                    url: format!("/runs/{stem}/{}", e.run_id),
6713                                });
6714                            }
6715                        }
6716                    }
6717                }
6718                links.sort_by(|a, b| a.name.cmp(&b.name));
6719                links
6720            };
6721            let submodule_names_csv = submodule_links
6722                .iter()
6723                .map(|l| l.name.as_str())
6724                .collect::<Vec<_>>()
6725                .join(",");
6726            HistoryEntryRow {
6727                run_id: e.run_id.clone(),
6728                run_id_short: e
6729                    .run_id
6730                    .split('-')
6731                    .next_back()
6732                    .unwrap_or(&e.run_id)
6733                    .chars()
6734                    .take(7)
6735                    .collect(),
6736                timestamp: fmt_la_time(e.timestamp_utc),
6737                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6738                project_label: e.project_label.clone(),
6739                project_path: e
6740                    .input_roots
6741                    .first()
6742                    .map(|s| sanitize_path_str(s))
6743                    .unwrap_or_default(),
6744                files_analyzed: e.summary.files_analyzed,
6745                files_skipped: e.summary.files_skipped,
6746                code_lines: e.summary.code_lines,
6747                comment_lines: e.summary.comment_lines,
6748                blank_lines: e.summary.blank_lines,
6749                git_branch: e.git_branch.clone().unwrap_or_default(),
6750                git_commit: e.git_commit.clone().unwrap_or_default(),
6751                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6752                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6753                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6754                submodule_links,
6755                submodule_names_csv,
6756            }
6757        })
6758        .collect()
6759}
6760
6761#[derive(Deserialize, Default)]
6762struct HistoryQuery {
6763    linked: Option<String>,
6764    error: Option<String>,
6765}
6766
6767async fn history_handler(
6768    State(state): State<AppState>,
6769    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6770    Query(query): Query<HistoryQuery>,
6771) -> impl IntoResponse {
6772    // Auto-scan all watched directories before rendering so the list stays fresh.
6773    auto_scan_watched_dirs(&state).await;
6774    let watched_dirs: Vec<String> = {
6775        let wd = state.watched_dirs.lock().await;
6776        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6777    };
6778    let mut entries = {
6779        let reg = state.registry.lock().await;
6780        make_history_rows(&reg)
6781    };
6782    entries.retain(|e| e.has_html);
6783    let total_scans = entries.len();
6784    let linked_count = query
6785        .linked
6786        .as_deref()
6787        .and_then(|s| s.parse::<usize>().ok())
6788        .unwrap_or(0);
6789    let browse_error = query.error.filter(|s| !s.is_empty());
6790    let template = HistoryTemplate {
6791        version: env!("CARGO_PKG_VERSION"),
6792        entries,
6793        total_scans,
6794        linked_count,
6795        browse_error,
6796        watched_dirs,
6797        csp_nonce,
6798        server_mode: state.server_mode,
6799    };
6800    Html(
6801        template
6802            .render()
6803            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6804    )
6805    .into_response()
6806}
6807
6808async fn compare_select_handler(
6809    State(state): State<AppState>,
6810    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6811) -> impl IntoResponse {
6812    auto_scan_watched_dirs(&state).await;
6813    let watched_dirs: Vec<String> = {
6814        let wd = state.watched_dirs.lock().await;
6815        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6816    };
6817    let mut entries = {
6818        let reg = state.registry.lock().await;
6819        make_history_rows(&reg)
6820    };
6821    entries.retain(|e| e.has_json);
6822    let total_scans = entries.len();
6823    let template = CompareSelectTemplate {
6824        version: env!("CARGO_PKG_VERSION"),
6825        entries,
6826        total_scans,
6827        watched_dirs,
6828        csp_nonce,
6829        server_mode: state.server_mode,
6830    };
6831    Html(
6832        template
6833            .render()
6834            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6835    )
6836    .into_response()
6837}
6838
6839// ── Compare ───────────────────────────────────────────────────────────────────
6840
6841#[derive(Deserialize, Default)]
6842struct CompareQuery {
6843    a: Option<String>,
6844    b: Option<String>,
6845    /// Optional submodule name to scope the comparison to one submodule.
6846    sub: Option<String>,
6847    /// "super" to exclude all submodule files and show only the super-repo.
6848    scope: Option<String>,
6849}
6850
6851struct CompareFileDeltaRow {
6852    relative_path: String,
6853    language: String,
6854    status: String,
6855    baseline_code: i64,
6856    current_code: i64,
6857    baseline_code_display: String,
6858    current_code_display: String,
6859    code_delta_str: String,
6860    code_delta_class: String,
6861    comment_delta_str: String,
6862    comment_delta_class: String,
6863    total_delta_str: String,
6864    total_delta_class: String,
6865}
6866
6867/// Recompute `summary_totals` from the current `per_file_records` slice.
6868/// Used when `per_file_records` has been narrowed to a submodule subset.
6869fn recompute_summary_from_records(run: &mut AnalysisRun) {
6870    let mut totals = SummaryTotals::default();
6871    for r in &run.per_file_records {
6872        if r.language.is_some() {
6873            totals.files_analyzed += 1;
6874        }
6875        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6876        totals.code_lines += r.effective_counts.code_lines;
6877        totals.comment_lines += r.effective_counts.comment_lines;
6878        totals.blank_lines += r.effective_counts.blank_lines;
6879        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6880        totals.functions += r.raw_line_categories.functions;
6881        totals.classes += r.raw_line_categories.classes;
6882        totals.variables += r.raw_line_categories.variables;
6883        totals.imports += r.raw_line_categories.imports;
6884        totals.test_count += r.raw_line_categories.test_count;
6885        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6886        totals.test_suite_count += r.raw_line_categories.test_suite_count;
6887        if let Some(cov) = &r.coverage {
6888            totals.coverage_lines_found += u64::from(cov.lines_found);
6889            totals.coverage_lines_hit += u64::from(cov.lines_hit);
6890            totals.coverage_functions_found += u64::from(cov.functions_found);
6891            totals.coverage_functions_hit += u64::from(cov.functions_hit);
6892            totals.coverage_branches_found += u64::from(cov.branches_found);
6893            totals.coverage_branches_hit += u64::from(cov.branches_hit);
6894        }
6895    }
6896    totals.files_considered = totals.files_analyzed;
6897    run.summary_totals = totals;
6898}
6899
6900fn fmt_delta(n: i64) -> String {
6901    if n > 0 {
6902        format!("+{n}")
6903    } else {
6904        format!("{n}")
6905    }
6906}
6907
6908fn delta_class(n: i64) -> &'static str {
6909    use std::cmp::Ordering;
6910    match n.cmp(&0) {
6911        Ordering::Greater => "pos",
6912        Ordering::Less => "neg",
6913        Ordering::Equal => "zero",
6914    }
6915}
6916
6917// ratio/percentage display, precision loss acceptable
6918#[allow(clippy::cast_precision_loss)]
6919fn fmt_pct(delta: i64, baseline: u64) -> String {
6920    if baseline == 0 {
6921        return "—".to_string();
6922    }
6923    #[allow(clippy::cast_precision_loss)]
6924    let pct = (delta as f64 / baseline as f64) * 100.0;
6925    if pct > 0.049 {
6926        format!("+{pct:.1}%")
6927    } else if pct < -0.049 {
6928        format!("{pct:.1}%")
6929    } else {
6930        "±0%".to_string()
6931    }
6932}
6933
6934/// Returns (`display_string`, `css_class`) for a numeric change column cell.
6935fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6936    prev.map_or_else(
6937        || ("—".to_string(), "na"),
6938        |p| {
6939            #[allow(clippy::cast_possible_wrap)]
6940            let d = curr as i64 - p as i64;
6941            (fmt_delta(d), delta_class(d))
6942        },
6943    )
6944}
6945
6946#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
6947fn load_scan_for_compare(
6948    json_path: &std::path::Path,
6949    scan_label: &str,
6950    run_id: &str,
6951    server_mode: bool,
6952    compare_url: &str,
6953    csp_nonce: &str,
6954) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6955    match read_json(json_path) {
6956        Ok(r) => Ok(r),
6957        Err(e) => {
6958            if server_mode {
6959                let html = ErrorTemplate {
6960                    message: format!(
6961                        "Could not load {scan_label} scan data. The scan output folder may have \
6962                         been moved, renamed, or deleted. Re-running the analysis will create \
6963                         fresh comparison data."
6964                    ),
6965                    last_report_url: Some("/compare-scans".to_string()),
6966                    last_report_label: Some("Compare Scans".to_string()),
6967                    run_id: Some(run_id.to_owned()),
6968                    error_code: Some(404),
6969                    csp_nonce: csp_nonce.to_owned(),
6970                    version: env!("CARGO_PKG_VERSION"),
6971                }
6972                .render()
6973                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6974                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6975            }
6976            let msg = format!(
6977                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6978                json_path.display()
6979            );
6980            let folder_hint = json_path
6981                .parent()
6982                .map(|p| p.display().to_string())
6983                .unwrap_or_default();
6984            Err(missing_scan_relocate_response(
6985                &msg,
6986                run_id,
6987                &folder_hint,
6988                compare_url,
6989                false,
6990                csp_nonce,
6991            ))
6992        }
6993    }
6994}
6995
6996struct ChurnStats {
6997    new_scope: bool,
6998    scope_flag: bool,
6999    churn_rate_str: String,
7000    churn_rate_class: String,
7001}
7002
7003fn compute_churn_stats(
7004    baseline_code: u64,
7005    current_code: u64,
7006    lines_added: i64,
7007    lines_removed: i64,
7008) -> ChurnStats {
7009    let new_scope = baseline_code == 0 && current_code > 0;
7010    #[allow(clippy::cast_precision_loss)]
7011    let churn_pct = if baseline_code > 0 {
7012        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7013    } else {
7014        0.0
7015    };
7016    #[allow(clippy::cast_precision_loss)]
7017    let scope_flag =
7018        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7019    let churn_rate_str = if new_scope {
7020        "New".to_string()
7021    } else if baseline_code > 0 {
7022        format!("{churn_pct:.1}%")
7023    } else {
7024        "—".to_string()
7025    };
7026    let churn_rate_class = if new_scope || churn_pct > 20.0 {
7027        "high".to_string()
7028    } else if churn_pct > 5.0 {
7029        "med".to_string()
7030    } else {
7031        "low".to_string()
7032    };
7033    ChurnStats {
7034        new_scope,
7035        scope_flag,
7036        churn_rate_str,
7037        churn_rate_class,
7038    }
7039}
7040
7041/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7042/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7043/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7044fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7045    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7046    if !has_data {
7047        return String::new();
7048    }
7049    let base_str = s
7050        .baseline_coverage_line_pct
7051        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7052    let curr_str = s
7053        .current_coverage_line_pct
7054        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7055    let (delta_str, cls) = match s.coverage_line_pct_delta {
7056        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7057        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7058        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7059        None => ("\u{2014}".into(), "zero"),
7060    };
7061    format!(
7062        r#"<div class="delta-card">
7063          <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>
7064          <div class="delta-card-label">Line coverage</div>
7065          <div class="delta-card-from">Before: {base_str}</div>
7066          <div class="delta-card-to">{curr_str}</div>
7067          <span class="delta-card-change {cls}">{delta_str}</span>
7068        </div>"#
7069    )
7070}
7071
7072/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
7073#[allow(clippy::ref_option)]
7074fn narrow_run_pair_by_scope(
7075    mut baseline: AnalysisRun,
7076    mut current: AnalysisRun,
7077    active_sub: &Option<String>,
7078    super_scope: bool,
7079) -> (AnalysisRun, AnalysisRun) {
7080    if let Some(ref sub_name) = active_sub {
7081        baseline
7082            .per_file_records
7083            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7084        current
7085            .per_file_records
7086            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7087        recompute_summary_from_records(&mut baseline);
7088        recompute_summary_from_records(&mut current);
7089    } else if super_scope {
7090        baseline.per_file_records.retain(|f| f.submodule.is_none());
7091        current.per_file_records.retain(|f| f.submodule.is_none());
7092        recompute_summary_from_records(&mut baseline);
7093        recompute_summary_from_records(&mut current);
7094    }
7095    (baseline, current)
7096}
7097
7098/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
7099#[allow(clippy::ref_option)]
7100fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7101    if let Some(ref sub_name) = active_sub {
7102        for run in runs.iter_mut() {
7103            run.per_file_records
7104                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7105            recompute_summary_from_records(run);
7106        }
7107    } else if super_scope {
7108        for run in runs.iter_mut() {
7109            run.per_file_records.retain(|f| f.submodule.is_none());
7110            recompute_summary_from_records(run);
7111        }
7112    }
7113}
7114
7115#[allow(clippy::too_many_lines)]
7116async fn compare_handler(
7117    State(state): State<AppState>,
7118    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7119    Query(query): Query<CompareQuery>,
7120) -> impl IntoResponse {
7121    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
7122    // redirect to the history page where the user can select two runs.
7123    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7124        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7125        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7126    };
7127
7128    let (maybe_a, maybe_b) = {
7129        let reg = state.registry.lock().await;
7130        (
7131            reg.find_by_run_id(&run_id_a).cloned(),
7132            reg.find_by_run_id(&run_id_b).cloned(),
7133        )
7134    };
7135
7136    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7137        let html = ErrorTemplate {
7138            message: "One or both run IDs were not found in scan history. \
7139                      The runs may have been deleted or the registry may have been reset."
7140                .to_string(),
7141            last_report_url: Some("/compare-scans".to_string()),
7142            last_report_label: Some("Compare Scans".to_string()),
7143            run_id: None,
7144            error_code: None,
7145            csp_nonce: csp_nonce.clone(),
7146            version: env!("CARGO_PKG_VERSION"),
7147        }
7148        .render()
7149        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7150        return Html(html).into_response();
7151    };
7152
7153    // Ensure older scan is always the baseline.
7154    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7155        (entry_a, entry_b)
7156    } else {
7157        (entry_b, entry_a)
7158    };
7159
7160    // If query params were in the wrong order, redirect to canonical URL so the
7161    // browser always shows the same URL for the same two scans regardless of how
7162    // the user arrived here (Full diff button vs. Compare Scans selection).
7163    if baseline_entry.run_id != run_id_a {
7164        let canonical = format!(
7165            "/compare?a={}&b={}",
7166            baseline_entry.run_id, current_entry.run_id
7167        );
7168        return axum::response::Redirect::to(&canonical).into_response();
7169    }
7170
7171    let (Some(base_json), Some(curr_json)) = (
7172        baseline_entry.json_path.as_ref(),
7173        current_entry.json_path.as_ref(),
7174    ) else {
7175        let html = ErrorTemplate {
7176            message: "Full comparison requires JSON scan data, which was not saved for one or \
7177                      both of these runs. JSON is now always saved for new scans — re-run the \
7178                      affected projects to enable comparisons."
7179                .to_string(),
7180            last_report_url: Some("/compare-scans".to_string()),
7181            last_report_label: Some("Compare Scans".to_string()),
7182            run_id: None,
7183            error_code: None,
7184            csp_nonce: csp_nonce.clone(),
7185            version: env!("CARGO_PKG_VERSION"),
7186        }
7187        .render()
7188        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7189        return Html(html).into_response();
7190    };
7191
7192    let compare_url = format!(
7193        "/compare?a={}&b={}",
7194        baseline_entry.run_id, current_entry.run_id
7195    );
7196
7197    let baseline_run = match load_scan_for_compare(
7198        base_json,
7199        "baseline",
7200        &baseline_entry.run_id,
7201        state.server_mode,
7202        &compare_url,
7203        &csp_nonce,
7204    ) {
7205        Ok(r) => r,
7206        Err(resp) => return resp,
7207    };
7208    let current_run = match load_scan_for_compare(
7209        curr_json,
7210        "current",
7211        &current_entry.run_id,
7212        state.server_mode,
7213        &compare_url,
7214        &csp_nonce,
7215    ) {
7216        Ok(r) => r,
7217        Err(resp) => return resp,
7218    };
7219
7220    let active_submodule = query.sub.clone();
7221    let super_scope_active = query.scope.as_deref() == Some("super");
7222
7223    let submodule_options = baseline_run
7224        .submodule_summaries
7225        .iter()
7226        .chain(current_run.submodule_summaries.iter())
7227        .map(|s| s.name.clone())
7228        .collect::<std::collections::BTreeSet<_>>()
7229        .into_iter()
7230        .collect::<Vec<_>>();
7231    let has_any_submodule_data = !submodule_options.is_empty();
7232
7233    // Narrow per_file_records when a scope is active, then recompute totals.
7234    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
7235        baseline_run,
7236        current_run,
7237        &active_submodule,
7238        super_scope_active,
7239    );
7240
7241    let comparison = compute_delta(&effective_baseline, &effective_current);
7242
7243    let file_rows: Vec<CompareFileDeltaRow> = comparison
7244        .file_deltas
7245        .iter()
7246        .map(|d| CompareFileDeltaRow {
7247            relative_path: d.relative_path.clone(),
7248            language: d.language.clone().unwrap_or_else(|| "—".into()),
7249            status: match d.status {
7250                FileChangeStatus::Added => "added".into(),
7251                FileChangeStatus::Removed => "removed".into(),
7252                FileChangeStatus::Modified => "modified".into(),
7253                FileChangeStatus::Unchanged => "unchanged".into(),
7254            },
7255            baseline_code: d.baseline_code,
7256            current_code: d.current_code,
7257            baseline_code_display: if d.status == FileChangeStatus::Added {
7258                "—".into()
7259            } else {
7260                d.baseline_code.to_string()
7261            },
7262            current_code_display: if d.status == FileChangeStatus::Removed {
7263                "—".into()
7264            } else {
7265                d.current_code.to_string()
7266            },
7267            code_delta_str: fmt_delta(d.code_delta),
7268            code_delta_class: delta_class(d.code_delta).into(),
7269            comment_delta_str: fmt_delta(d.comment_delta),
7270            comment_delta_class: delta_class(d.comment_delta).into(),
7271            total_delta_str: fmt_delta(d.total_delta),
7272            total_delta_class: delta_class(d.total_delta).into(),
7273        })
7274        .collect();
7275
7276    let project_path = baseline_entry
7277        .input_roots
7278        .first()
7279        .map(|s| sanitize_path_str(s))
7280        .unwrap_or_default();
7281    let lines_added = sum_added_code_lines(&comparison);
7282    let lines_removed = sum_removed_code_lines(&comparison);
7283    let churn = compute_churn_stats(
7284        comparison.summary.baseline_code,
7285        comparison.summary.current_code,
7286        lines_added,
7287        lines_removed,
7288    );
7289    let s = &comparison.summary;
7290    let template = CompareTemplate {
7291        version: env!("CARGO_PKG_VERSION"),
7292        project_label: baseline_entry.project_label.clone(),
7293        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
7294        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
7295        baseline_run_id: baseline_entry.run_id.clone(),
7296        current_run_id: current_entry.run_id.clone(),
7297        baseline_run_id_short: baseline_entry
7298            .run_id
7299            .split('-')
7300            .next_back()
7301            .unwrap_or(&baseline_entry.run_id)
7302            .chars()
7303            .take(7)
7304            .collect(),
7305        current_run_id_short: current_entry
7306            .run_id
7307            .split('-')
7308            .next_back()
7309            .unwrap_or(&current_entry.run_id)
7310            .chars()
7311            .take(7)
7312            .collect(),
7313        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
7314        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
7315        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
7316        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
7317        project_path: project_path.clone(),
7318        baseline_code: s.baseline_code,
7319        current_code: s.current_code,
7320        code_lines_delta_str: fmt_delta(s.code_lines_delta),
7321        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
7322        baseline_files: s.baseline_files,
7323        current_files: s.current_files,
7324        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
7325        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
7326        baseline_comments: s.baseline_comments,
7327        current_comments: s.current_comments,
7328        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
7329        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
7330        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
7331        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
7332        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
7333        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
7334        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
7335        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
7336        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
7337        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
7338        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
7339        code_lines_added: lines_added,
7340        code_lines_removed: lines_removed,
7341        new_scope: churn.new_scope,
7342        churn_rate_str: churn.churn_rate_str,
7343        churn_rate_class: churn.churn_rate_class,
7344        scope_flag: churn.scope_flag,
7345        files_added: comparison.files_added,
7346        files_removed: comparison.files_removed,
7347        files_modified: comparison.files_modified,
7348        files_unchanged: comparison.files_unchanged,
7349        file_rows,
7350        baseline_git_author: baseline_entry.git_author.clone(),
7351        current_git_author: current_entry.git_author.clone(),
7352        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
7353        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
7354        baseline_git_tags: baseline_entry.git_tags.clone(),
7355        current_git_tags: current_entry.git_tags.clone(),
7356        baseline_git_commit_date: baseline_entry
7357            .git_commit_date
7358            .as_deref()
7359            .and_then(fmt_git_date),
7360        current_git_commit_date: current_entry
7361            .git_commit_date
7362            .as_deref()
7363            .and_then(fmt_git_date),
7364        project_name: project_path
7365            .rsplit(['/', '\\'])
7366            .find(|s| !s.is_empty())
7367            .unwrap_or(&project_path)
7368            .to_string(),
7369        submodule_options,
7370        has_any_submodule_data,
7371        active_submodule,
7372        super_scope_active,
7373        csp_nonce,
7374        coverage_delta_card: build_coverage_delta_card(s),
7375        baseline_test_count: effective_baseline.summary_totals.test_count,
7376        current_test_count: effective_current.summary_totals.test_count,
7377        baseline_coverage_pct: s.baseline_coverage_line_pct,
7378        current_coverage_pct: s.current_coverage_line_pct,
7379    };
7380
7381    Html(
7382        template
7383            .render()
7384            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7385    )
7386    .into_response()
7387}
7388
7389// ── Badge endpoint ────────────────────────────────────────────────────────────
7390// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
7391// pages, Jira descriptions, etc.
7392//
7393// GET /badge/<metric>?label=<override>&color=<hex>
7394// Metrics: code-lines  files  comment-lines  blank-lines
7395
7396fn format_number(n: u64) -> String {
7397    let s = n.to_string();
7398    let mut out = String::with_capacity(s.len() + s.len() / 3);
7399    let len = s.len();
7400    for (i, c) in s.chars().enumerate() {
7401        if i > 0 && (len - i).is_multiple_of(3) {
7402            out.push(',');
7403        }
7404        out.push(c);
7405    }
7406    out
7407}
7408
7409const fn badge_char_width(c: char) -> f64 {
7410    match c {
7411        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7412        'm' | 'w' => 9.0,
7413        ' ' => 4.0,
7414        _ => 6.5,
7415    }
7416}
7417
7418#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7419fn badge_text_px(text: &str) -> u32 {
7420    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7421}
7422
7423fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7424    let lw = badge_text_px(label) + 20;
7425    let rw = badge_text_px(value) + 20;
7426    let total = lw + rw;
7427    let lx = lw / 2;
7428    let rx = lw + rw / 2;
7429    let le = escape_html(label);
7430    let ve = escape_html(value);
7431    let ce = escape_html(color);
7432    format!(
7433        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7434  <rect width="{total}" height="20" fill="#555"/>
7435  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7436  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7437    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7438    <text x="{lx}" y="13">{le}</text>
7439    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7440    <text x="{rx}" y="13">{ve}</text>
7441  </g>
7442</svg>"##
7443    )
7444}
7445
7446#[derive(Deserialize)]
7447struct BadgeQuery {
7448    label: Option<String>,
7449    color: Option<String>,
7450}
7451
7452async fn badge_handler(
7453    State(state): State<AppState>,
7454    AxumPath(metric): AxumPath<String>,
7455    Query(query): Query<BadgeQuery>,
7456) -> Response {
7457    let entry = {
7458        let reg = state.registry.lock().await;
7459        reg.entries.first().cloned()
7460    };
7461
7462    let Some(entry) = entry else {
7463        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7464        return (
7465            [
7466                (header::CONTENT_TYPE, "image/svg+xml"),
7467                (header::CACHE_CONTROL, "no-cache, max-age=0"),
7468            ],
7469            svg,
7470        )
7471            .into_response();
7472    };
7473
7474    let (default_label, value, default_color) = match metric.as_str() {
7475        "code-lines" => (
7476            "code lines",
7477            format_number(entry.summary.code_lines),
7478            "#4a78ee",
7479        ),
7480        "files" => (
7481            "files analyzed",
7482            format_number(entry.summary.files_analyzed),
7483            "#4a9862",
7484        ),
7485        "comment-lines" => (
7486            "comment lines",
7487            format_number(entry.summary.comment_lines),
7488            "#b35428",
7489        ),
7490        "blank-lines" => (
7491            "blank lines",
7492            format_number(entry.summary.blank_lines),
7493            "#7a5db0",
7494        ),
7495        _ => return StatusCode::NOT_FOUND.into_response(),
7496    };
7497
7498    let label = query.label.as_deref().unwrap_or(default_label);
7499    let color = query.color.as_deref().unwrap_or(default_color);
7500    let svg = render_badge_svg(label, &value, color);
7501
7502    (
7503        [
7504            (header::CONTENT_TYPE, "image/svg+xml"),
7505            (header::CACHE_CONTROL, "no-cache, max-age=0"),
7506        ],
7507        svg,
7508    )
7509        .into_response()
7510}
7511
7512// ── Metrics API ───────────────────────────────────────────────────────────────
7513// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
7514// Confluence automation, Jira webhooks, etc.
7515//
7516// GET /api/metrics/latest
7517// GET /api/metrics/<run_id>
7518
7519#[derive(Serialize)]
7520struct ApiCoverageBlock {
7521    lines_found: u64,
7522    lines_hit: u64,
7523    line_pct: f64,
7524    functions_found: u64,
7525    functions_hit: u64,
7526    function_pct: f64,
7527    branches_found: u64,
7528    branches_hit: u64,
7529    branch_pct: f64,
7530}
7531
7532#[derive(Serialize)]
7533struct ApiMetricsResponse {
7534    run_id: String,
7535    timestamp: String,
7536    project: String,
7537    summary: ApiSummaryPayload,
7538    languages: Vec<ApiLanguageRow>,
7539    #[serde(skip_serializing_if = "Option::is_none")]
7540    coverage: Option<ApiCoverageBlock>,
7541}
7542
7543#[derive(Serialize)]
7544struct ApiSummaryPayload {
7545    files_analyzed: u64,
7546    files_skipped: u64,
7547    code_lines: u64,
7548    comment_lines: u64,
7549    blank_lines: u64,
7550    total_physical_lines: u64,
7551    functions: u64,
7552    classes: u64,
7553    variables: u64,
7554    imports: u64,
7555}
7556
7557#[derive(Serialize)]
7558struct ApiLanguageRow {
7559    name: String,
7560    files: u64,
7561    code_lines: u64,
7562    comment_lines: u64,
7563    blank_lines: u64,
7564    functions: u64,
7565    classes: u64,
7566    variables: u64,
7567    imports: u64,
7568}
7569
7570async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7571    let entry = {
7572        let reg = state.registry.lock().await;
7573        reg.entries.first().cloned()
7574    };
7575    entry.map_or_else(
7576        || error::not_found("no scans recorded yet"),
7577        |e| build_metrics_response(&e),
7578    )
7579}
7580
7581async fn api_metrics_run_handler(
7582    State(state): State<AppState>,
7583    AxumPath(run_id): AxumPath<String>,
7584) -> Response {
7585    let entry = {
7586        let reg = state.registry.lock().await;
7587        reg.find_by_run_id(&run_id).cloned()
7588    };
7589    entry.map_or_else(
7590        || error::not_found("run not found"),
7591        |e| build_metrics_response(&e),
7592    )
7593}
7594
7595fn build_metrics_response(entry: &RegistryEntry) -> Response {
7596    let languages: Vec<ApiLanguageRow> = entry
7597        .json_path
7598        .as_ref()
7599        .and_then(|p| read_json(p).ok())
7600        .map(|run| {
7601            run.totals_by_language
7602                .iter()
7603                .map(|l| ApiLanguageRow {
7604                    name: l.language.display_name().to_string(),
7605                    files: l.files,
7606                    code_lines: l.code_lines,
7607                    comment_lines: l.comment_lines,
7608                    blank_lines: l.blank_lines,
7609                    functions: l.functions,
7610                    classes: l.classes,
7611                    variables: l.variables,
7612                    imports: l.imports,
7613                })
7614                .collect()
7615        })
7616        .unwrap_or_default();
7617
7618    let s = &entry.summary;
7619    let coverage = if s.coverage_lines_found > 0 {
7620        let pct = |hit: u64, found: u64| -> f64 {
7621            if found == 0 {
7622                0.0
7623            } else {
7624                #[allow(clippy::cast_precision_loss)]
7625                let v = (hit as f64 / found as f64) * 100.0;
7626                (v * 10.0).round() / 10.0
7627            }
7628        };
7629        Some(ApiCoverageBlock {
7630            lines_found: s.coverage_lines_found,
7631            lines_hit: s.coverage_lines_hit,
7632            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7633            functions_found: s.coverage_functions_found,
7634            functions_hit: s.coverage_functions_hit,
7635            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7636            branches_found: s.coverage_branches_found,
7637            branches_hit: s.coverage_branches_hit,
7638            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7639        })
7640    } else {
7641        None
7642    };
7643    Json(ApiMetricsResponse {
7644        run_id: entry.run_id.clone(),
7645        timestamp: entry.timestamp_utc.to_rfc3339(),
7646        project: entry.project_label.clone(),
7647        summary: ApiSummaryPayload {
7648            files_analyzed: s.files_analyzed,
7649            files_skipped: s.files_skipped,
7650            code_lines: s.code_lines,
7651            comment_lines: s.comment_lines,
7652            blank_lines: s.blank_lines,
7653            total_physical_lines: s.total_physical_lines,
7654            functions: s.functions,
7655            classes: s.classes,
7656            variables: s.variables,
7657            imports: s.imports,
7658        },
7659        languages,
7660        coverage,
7661    })
7662    .into_response()
7663}
7664
7665// ── Project history API ───────────────────────────────────────────────────────
7666// Protected. Called by the wizard JS when the project path changes, so the UI
7667// can show a "scanned N times before" badge without a full page reload.
7668//
7669// GET /api/project-history?path=<project_root>
7670
7671#[derive(Deserialize)]
7672struct ProjectHistoryQuery {
7673    path: Option<String>,
7674}
7675
7676#[derive(Serialize)]
7677struct ProjectHistoryResponse {
7678    scan_count: usize,
7679    last_scan_id: Option<String>,
7680    last_scan_timestamp: Option<String>,
7681    last_scan_code_lines: Option<u64>,
7682    last_git_branch: Option<String>,
7683    last_git_commit: Option<String>,
7684}
7685
7686/// Return true if `entry` matches either an exact root path or an upload-staging
7687/// path with the same project name (needed because each upload gets a fresh UUID dir).
7688fn entry_matches_project(
7689    entry: &RegistryEntry,
7690    root_str: &str,
7691    upload_root: &str,
7692    upload_name_suffix: Option<&str>,
7693) -> bool {
7694    if entry.input_roots.iter().any(|r| r == root_str) {
7695        return true;
7696    }
7697    if let Some(suffix) = upload_name_suffix {
7698        return entry
7699            .input_roots
7700            .iter()
7701            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7702    }
7703    false
7704}
7705
7706async fn project_history_handler(
7707    State(state): State<AppState>,
7708    Query(query): Query<ProjectHistoryQuery>,
7709) -> Response {
7710    let path = query.path.unwrap_or_default();
7711    let resolved = resolve_input_path(&path);
7712    let root_str = resolved.to_string_lossy().replace('\\', "/");
7713
7714    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
7715    // The UUID is freshly generated for every upload, so an exact root_str match never finds
7716    // previous scans of the same project. Fall back to matching by project name within the
7717    // uploads staging directory so Scan History populates correctly across uploads.
7718    let upload_root = std::env::temp_dir()
7719        .join("oxide-sloc-uploads")
7720        .to_string_lossy()
7721        .replace('\\', "/");
7722    let upload_name_suffix: Option<String> =
7723        if state.server_mode && root_str.starts_with(&upload_root) {
7724            resolved
7725                .file_name()
7726                .and_then(|n| n.to_str())
7727                .map(|name| format!("/{name}"))
7728        } else {
7729            None
7730        };
7731    let suffix_ref = upload_name_suffix.as_deref();
7732
7733    let entries: Vec<_> = {
7734        let reg = state.registry.lock().await;
7735        reg.entries
7736            .iter()
7737            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7738            .cloned()
7739            .collect()
7740    };
7741    let scan_count = entries.len();
7742    let last = entries.first();
7743    let last_scan_id = last.map(|e| e.run_id.clone());
7744    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7745    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7746    let last_git_branch = last.and_then(|e| e.git_branch.clone());
7747    let last_git_commit = last.and_then(|e| e.git_commit.clone());
7748
7749    Json(ProjectHistoryResponse {
7750        scan_count,
7751        last_scan_id,
7752        last_scan_timestamp,
7753        last_scan_code_lines,
7754        last_git_branch,
7755        last_git_commit,
7756    })
7757    .into_response()
7758}
7759
7760// ── Metrics history API ───────────────────────────────────────────────────────
7761// Protected. Returns a JSON array of lightweight scan snapshots for plotting
7762// trend charts.
7763//
7764// GET /api/metrics/history?root=<path>&limit=<n>
7765
7766#[derive(Deserialize)]
7767struct MetricsHistoryQuery {
7768    root: Option<String>,
7769    limit: Option<usize>,
7770    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
7771    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
7772    submodule: Option<String>,
7773}
7774
7775#[derive(Serialize)]
7776struct MetricsSubmoduleLink {
7777    name: String,
7778    url: String,
7779}
7780
7781#[derive(Serialize)]
7782struct MetricsHistoryEntry {
7783    run_id: String,
7784    run_id_short: String,
7785    timestamp: String,
7786    commit: Option<String>,
7787    branch: Option<String>,
7788    tags: Vec<String>,
7789    nearest_tag: Option<String>,
7790    code_lines: u64,
7791    comment_lines: u64,
7792    blank_lines: u64,
7793    physical_lines: u64,
7794    files_analyzed: u64,
7795    files_skipped: u64,
7796    test_count: u64,
7797    project_label: String,
7798    html_url: Option<String>,
7799    has_pdf: bool,
7800    submodule_links: Vec<MetricsSubmoduleLink>,
7801    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
7802    #[serde(skip_serializing_if = "Option::is_none")]
7803    coverage_line_pct: Option<f64>,
7804}
7805
7806fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7807    let mut links: Vec<MetricsSubmoduleLink> = vec![];
7808    let sub_dir = e
7809        .html_path
7810        .as_ref()
7811        .and_then(|p| p.parent())
7812        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7813    let Some(dir) = sub_dir else { return links };
7814    let Ok(rd) = std::fs::read_dir(dir) else {
7815        return links;
7816    };
7817    for entry_res in rd.flatten() {
7818        let fname = entry_res.file_name();
7819        let fname_str = fname.to_string_lossy();
7820        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7821            let stem = &fname_str[..fname_str.len() - 5];
7822            let display = stem[4..].replace('-', " ");
7823            links.push(MetricsSubmoduleLink {
7824                name: display,
7825                url: format!("/runs/{stem}/{}", e.run_id),
7826            });
7827        }
7828    }
7829    links.sort_by(|a, b| a.name.cmp(&b.name));
7830    links
7831}
7832
7833fn apply_submodule_filter(
7834    base: MetricsHistoryEntry,
7835    filter: &str,
7836    e: &sloc_core::history::RegistryEntry,
7837) -> Option<MetricsHistoryEntry> {
7838    let json_path = e.json_path.as_ref()?;
7839    let json_str = std::fs::read_to_string(json_path).ok()?;
7840    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7841    let sub = run
7842        .submodule_summaries
7843        .iter()
7844        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7845    let safe = sanitize_project_label(&sub.name);
7846    let artifact_key = format!("sub_{safe}");
7847    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7848        || base.html_url.clone(),
7849        |run_dir| {
7850            let sub_path = run_dir.join(format!("{artifact_key}.html"));
7851            if sub_path.exists() {
7852                Some(format!("/runs/{artifact_key}/{}", e.run_id))
7853            } else {
7854                base.html_url.clone()
7855            }
7856        },
7857    );
7858
7859    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
7860    // basic SLOC totals, so test_count and coverage must be computed from file records.
7861    let sub_files: Vec<_> = run
7862        .per_file_records
7863        .iter()
7864        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7865        .collect();
7866    let test_count: u64 = sub_files
7867        .iter()
7868        .map(|r| r.raw_line_categories.test_count)
7869        .sum();
7870    #[allow(clippy::cast_precision_loss)]
7871    let coverage_line_pct: Option<f64> = {
7872        let found: u64 = sub_files
7873            .iter()
7874            .filter_map(|r| r.coverage.as_ref())
7875            .map(|c| u64::from(c.lines_found))
7876            .sum();
7877        let hit: u64 = sub_files
7878            .iter()
7879            .filter_map(|r| r.coverage.as_ref())
7880            .map(|c| u64::from(c.lines_hit))
7881            .sum();
7882        if found > 0 {
7883            let pct = (hit as f64 / found as f64) * 100.0;
7884            Some((pct * 10.0).round() / 10.0)
7885        } else {
7886            None
7887        }
7888    };
7889
7890    Some(MetricsHistoryEntry {
7891        code_lines: sub.code_lines,
7892        comment_lines: sub.comment_lines,
7893        blank_lines: sub.blank_lines,
7894        physical_lines: sub.total_physical_lines,
7895        files_analyzed: sub.files_analyzed,
7896        files_skipped: 0,
7897        test_count,
7898        html_url: sub_html_url,
7899        has_pdf: false,
7900        submodule_links: vec![],
7901        coverage_line_pct,
7902        ..base
7903    })
7904}
7905
7906#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
7907async fn api_metrics_history_handler(
7908    State(state): State<AppState>,
7909    Query(query): Query<MetricsHistoryQuery>,
7910) -> Response {
7911    let limit = query.limit.unwrap_or(50).min(500);
7912    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7913
7914    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7915        let reg = state.registry.lock().await;
7916        reg.entries
7917            .iter()
7918            .filter(|e| {
7919                query.root.as_ref().is_none_or(|root| {
7920                    let resolved = resolve_input_path(root);
7921                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7922                    e.input_roots.iter().any(|r| r == &root_str)
7923                })
7924            })
7925            .take(limit)
7926            .cloned()
7927            .collect()
7928    };
7929
7930    let entries: Vec<MetricsHistoryEntry> = candidate_entries
7931        .into_iter()
7932        .filter_map(|e| {
7933            let tags = e
7934                .git_tags
7935                .as_deref()
7936                .map(|s| {
7937                    s.split(',')
7938                        .map(|t| t.trim().to_string())
7939                        .filter(|t| !t.is_empty())
7940                        .collect()
7941                })
7942                .unwrap_or_default();
7943            let html_url = e
7944                .html_path
7945                .as_ref()
7946                .filter(|p| p.exists())
7947                .map(|_| format!("/runs/html/{}", e.run_id));
7948            let nearest_tag = e.git_nearest_tag.clone();
7949            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7950            let run_id_short: String = e
7951                .run_id
7952                .split('-')
7953                .next_back()
7954                .unwrap_or(&e.run_id)
7955                .chars()
7956                .take(7)
7957                .collect();
7958            let submodule_links = build_entry_submodule_links(&e);
7959            #[allow(clippy::cast_precision_loss)]
7960            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7961                let pct = (e.summary.coverage_lines_hit as f64
7962                    / e.summary.coverage_lines_found as f64)
7963                    * 100.0;
7964                Some((pct * 10.0).round() / 10.0)
7965            } else {
7966                None
7967            };
7968            let base = MetricsHistoryEntry {
7969                run_id: e.run_id.clone(),
7970                run_id_short,
7971                timestamp: e.timestamp_utc.to_rfc3339(),
7972                commit: e.git_commit.clone(),
7973                branch: e.git_branch.clone(),
7974                tags,
7975                nearest_tag,
7976                code_lines: e.summary.code_lines,
7977                comment_lines: e.summary.comment_lines,
7978                blank_lines: e.summary.blank_lines,
7979                physical_lines: e.summary.total_physical_lines,
7980                files_analyzed: e.summary.files_analyzed,
7981                files_skipped: e.summary.files_skipped,
7982                test_count: e.summary.test_count,
7983                project_label: e.project_label.clone(),
7984                html_url,
7985                has_pdf,
7986                submodule_links,
7987                coverage_line_pct,
7988            };
7989            if let Some(ref filter) = submodule_filter {
7990                apply_submodule_filter(base, filter, &e)
7991            } else {
7992                Some(base)
7993            }
7994        })
7995        .collect();
7996
7997    Json(entries).into_response()
7998}
7999
8000// GET /api/metrics/submodules?root=<path>
8001// Returns the union of distinct submodule names found across all saved scan JSON artifacts
8002// for the given project root (or all roots if omitted).
8003#[derive(Deserialize)]
8004struct MetricsSubmodulesQuery {
8005    root: Option<String>,
8006}
8007
8008#[derive(Serialize)]
8009struct SubmoduleEntry {
8010    name: String,
8011    relative_path: String,
8012}
8013
8014async fn api_metrics_submodules_handler(
8015    State(state): State<AppState>,
8016    Query(query): Query<MetricsSubmodulesQuery>,
8017) -> Response {
8018    let json_paths: Vec<std::path::PathBuf> = {
8019        let reg = state.registry.lock().await;
8020        reg.entries
8021            .iter()
8022            .filter(|e| {
8023                query.root.as_ref().is_none_or(|root| {
8024                    let resolved = resolve_input_path(root);
8025                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8026                    e.input_roots.iter().any(|r| r == &root_str)
8027                })
8028            })
8029            .filter_map(|e| e.json_path.clone())
8030            .collect()
8031    };
8032
8033    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8034    let mut result: Vec<SubmoduleEntry> = Vec::new();
8035
8036    for path in &json_paths {
8037        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8038            continue;
8039        };
8040        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8041            continue;
8042        };
8043        for sub in &run.submodule_summaries {
8044            if seen.insert(sub.name.clone()) {
8045                result.push(SubmoduleEntry {
8046                    name: sub.name.clone(),
8047                    relative_path: sub.relative_path.clone(),
8048                });
8049            }
8050        }
8051    }
8052
8053    result.sort_by(|a, b| a.name.cmp(&b.name));
8054    Json(result).into_response()
8055}
8056
8057// ── CI ingest endpoint ────────────────────────────────────────────────────────
8058// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
8059// server stores and displays results without cloning or scanning anything itself.
8060//
8061// POST /api/ingest?label=<optional_display_name>
8062// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
8063// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
8064
8065#[derive(Deserialize)]
8066struct IngestQuery {
8067    label: Option<String>,
8068}
8069
8070#[derive(Serialize)]
8071struct IngestResponse {
8072    run_id: String,
8073    view_url: String,
8074}
8075
8076async fn api_ingest_handler(
8077    State(state): State<AppState>,
8078    Query(q): Query<IngestQuery>,
8079    Json(run): Json<sloc_core::AnalysisRun>,
8080) -> Response {
8081    let label = q.label.unwrap_or_else(|| {
8082        run.input_roots
8083            .first()
8084            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8085    });
8086
8087    let label_for_task = label.clone();
8088    let result = tokio::task::spawn_blocking(move || {
8089        let html = render_html(&run)?;
8090        let run_id = run.tool.run_id.clone();
8091        let run_id_safe = run_id.len() <= 128
8092            && !run_id.is_empty()
8093            && run_id
8094                .chars()
8095                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8096        if !run_id_safe {
8097            anyhow::bail!(
8098                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8099            );
8100        }
8101        let project_label = sanitize_project_label(&label_for_task);
8102        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8103        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8104            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8105            _ => project_label,
8106        };
8107        let (artifacts, _pending_pdf) = persist_run_artifacts(
8108            &run,
8109            &html,
8110            &output_dir,
8111            &label_for_task,
8112            &file_stem,
8113            RunResultContext::default(),
8114        )?;
8115        Ok::<_, anyhow::Error>((run_id, artifacts, run))
8116    })
8117    .await;
8118
8119    match result {
8120        Ok(Ok((run_id, artifacts, run))) => {
8121            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
8122            (
8123                StatusCode::CREATED,
8124                Json(IngestResponse {
8125                    view_url: format!("/view-reports?run_id={run_id}"),
8126                    run_id,
8127                }),
8128            )
8129                .into_response()
8130        }
8131        Ok(Err(e)) => error::internal(&format!("{e:#}")),
8132        Err(e) => error::internal(&format!("{e}")),
8133    }
8134}
8135
8136// ── Multi-compare page ────────────────────────────────────────────────────────
8137// GET /multi-compare?runs=id1,id2,id3,...
8138
8139fn html_escape(s: &str) -> String {
8140    s.replace('&', "&amp;")
8141        .replace('<', "&lt;")
8142        .replace('>', "&gt;")
8143        .replace('"', "&quot;")
8144}
8145
8146#[allow(clippy::cast_precision_loss)]
8147fn fmt_num(n: i64) -> String {
8148    let a = n.unsigned_abs();
8149    if a >= 1_000_000 {
8150        let v = n as f64 / 1_000_000.0;
8151        let s = format!("{v:.1}");
8152        format!("{}M", s.trim_end_matches(".0"))
8153    } else if a >= 10_000 {
8154        let v = n as f64 / 1_000.0;
8155        let s = format!("{v:.1}");
8156        format!("{}K", s.trim_end_matches(".0"))
8157    } else {
8158        let sign = if n < 0 { "-" } else { "" };
8159        if a < 1_000 {
8160            return format!("{sign}{a}");
8161        }
8162        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
8163    }
8164}
8165
8166fn fmt_comma(n: i64) -> String {
8167    let sign = if n < 0 { "-" } else { "" };
8168    let a = n.unsigned_abs();
8169    if a < 1_000 {
8170        return format!("{sign}{a}");
8171    }
8172    let s = a.to_string();
8173    let bytes = s.as_bytes();
8174    let len = bytes.len();
8175    let mut out = String::with_capacity(len + len / 3);
8176    for (i, &b) in bytes.iter().enumerate() {
8177        if i > 0 && (len - i).is_multiple_of(3) {
8178            out.push(',');
8179        }
8180        out.push(b as char);
8181    }
8182    format!("{sign}{out}")
8183}
8184
8185#[derive(Deserialize, Default)]
8186struct MultiCompareQuery {
8187    runs: Option<String>,
8188    /// "super" to show only super-repo files (exclude all submodule files)
8189    scope: Option<String>,
8190    /// Submodule name to narrow the comparison to one submodule
8191    sub: Option<String>,
8192}
8193
8194#[allow(clippy::too_many_lines)]
8195async fn multi_compare_handler(
8196    State(state): State<AppState>,
8197    Query(params): Query<MultiCompareQuery>,
8198    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8199) -> impl IntoResponse {
8200    let run_ids: Vec<String> = params
8201        .runs
8202        .as_deref()
8203        .unwrap_or("")
8204        .split(',')
8205        .map(|s| s.trim().to_string())
8206        .filter(|s| !s.is_empty())
8207        .collect();
8208
8209    if run_ids.len() < 2 {
8210        return Html(
8211            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
8212             <a href=\"/compare-scans\">Go back</a></p>",
8213        )
8214        .into_response();
8215    }
8216    if run_ids.len() > 20 {
8217        return Html(
8218            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
8219             at once. <a href=\"/compare-scans\">Go back</a></p>",
8220        )
8221        .into_response();
8222    }
8223
8224    // Look up each run_id in the registry.
8225    let entries: Vec<Option<RegistryEntry>> = {
8226        let reg = state.registry.lock().await;
8227        run_ids
8228            .iter()
8229            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
8230            .collect()
8231    };
8232
8233    for (i, entry) in entries.iter().enumerate() {
8234        if entry.is_none() {
8235            let html = format!(
8236                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
8237                 found. <a href=\"/compare-scans\">Go back</a></p>",
8238                run_ids[i]
8239            );
8240            return Html(html).into_response();
8241        }
8242    }
8243
8244    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
8245
8246    for entry in &entries {
8247        if entry.json_path.is_none() {
8248            let html = format!(
8249                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
8250                 JSON data — re-run the analysis to enable comparison. \
8251                 <a href=\"/compare-scans\">Go back</a></p>",
8252                &entry.run_id
8253            );
8254            return Html(html).into_response();
8255        }
8256    }
8257
8258    // Sort chronologically.
8259    entries.sort_by_key(|e| e.timestamp_utc);
8260
8261    // Load JSON for each entry.
8262    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
8263    for entry in &entries {
8264        let path = entry.json_path.as_ref().unwrap();
8265        match read_json(path) {
8266            Ok(r) => runs.push(r),
8267            Err(e) => {
8268                let html = format!(
8269                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
8270                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
8271                    &entry.run_id
8272                );
8273                return Html(html).into_response();
8274            }
8275        }
8276    }
8277
8278    // Collect submodule names from all runs.
8279    let all_sub_names: Vec<String> = {
8280        let mut set = std::collections::BTreeSet::new();
8281        for r in &runs {
8282            for s in &r.submodule_summaries {
8283                set.insert(s.name.clone());
8284            }
8285        }
8286        set.into_iter().collect()
8287    };
8288    let has_submodule_data = !all_sub_names.is_empty();
8289    let active_submodule = params.sub.clone();
8290    let super_scope_active = params.scope.as_deref() == Some("super");
8291
8292    // Narrow per_file_records when a scope is active, then recompute totals.
8293    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
8294
8295    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
8296    let project_label = entries
8297        .first()
8298        .map_or("", |e| e.project_label.as_str())
8299        .to_string();
8300    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
8301    let multi = compute_multi_delta(&run_refs);
8302    let html = multi_compare_page(
8303        &multi,
8304        &project_label,
8305        env!("CARGO_PKG_VERSION"),
8306        &csp_nonce,
8307        has_submodule_data,
8308        &all_sub_names,
8309        &runs_csv,
8310        super_scope_active,
8311        active_submodule.as_deref(),
8312        &entries,
8313    );
8314    // no-store: this page is regenerated on every request and embeds inline JS; a cached
8315    // copy after a rebuild would silently mask UI fixes.
8316    (
8317        [(axum::http::header::CACHE_CONTROL, "no-store")],
8318        Html(html),
8319    )
8320        .into_response()
8321}
8322
8323const fn multi_delta_class(n: i64) -> &'static str {
8324    match n {
8325        1.. => "pos",
8326        ..=-1 => "neg",
8327        0 => "zero",
8328    }
8329}
8330
8331fn multi_fmt_delta(n: i64) -> String {
8332    if n > 0 {
8333        format!("+{n}")
8334    } else {
8335        format!("{n}")
8336    }
8337}
8338
8339/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
8340fn js_escape(s: &str) -> String {
8341    use std::fmt::Write as _;
8342    let mut out = String::with_capacity(s.len() + 2);
8343    for c in s.chars() {
8344        match c {
8345            '"' => out.push_str("\\\""),
8346            '\\' => out.push_str("\\\\"),
8347            '\n' => out.push_str("\\n"),
8348            '\r' => out.push_str("\\r"),
8349            '\t' => out.push_str("\\t"),
8350            c if (c as u32) < 0x20 => {
8351                let _ = write!(out, "\\u{:04x}", c as u32);
8352            }
8353            c => out.push(c),
8354        }
8355    }
8356    out
8357}
8358
8359/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
8360fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
8361    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
8362        return (
8363            "&mdash;".to_string(),
8364            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
8365        );
8366    };
8367    let cd = entry
8368        .git_commit_date
8369        .as_deref()
8370        .and_then(fmt_git_date)
8371        .unwrap_or_else(|| "&mdash;".to_string());
8372    let au = entry.git_author.as_deref().map_or_else(
8373        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
8374        |a| {
8375            format!(
8376                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
8377                 <span class=\"cmp-author-handle\"></span></span>",
8378                html_escape(a)
8379            )
8380        },
8381    );
8382    (cd, au)
8383}
8384
8385/// Render the scope badge chip for a scan card header.
8386fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
8387    active_sub.map_or_else(
8388        || {
8389            if super_scope_active {
8390                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
8391            } else {
8392                "<span class=\"mc-scope-tag mc-scope-full\">\
8393                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
8394                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
8395                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
8396                 <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>\
8397                 </svg> Full scan</span>"
8398                    .to_string()
8399            }
8400        },
8401        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
8402    )
8403}
8404
8405/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
8406fn build_mc_scan_strip(
8407    multi: &MultiScanComparison,
8408    entries: &[RegistryEntry],
8409    n: usize,
8410    is_many: bool,
8411    active_sub: Option<&str>,
8412    super_scope_active: bool,
8413    project_label: &str,
8414) -> String {
8415    use std::fmt::Write as _;
8416    let mut scan_strip = String::new();
8417    for (i, pt) in multi.points.iter().enumerate() {
8418        let ts_ms = pt.timestamp.timestamp_millis();
8419        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8420        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
8421        let branch = pt.git_branch.as_deref().unwrap_or("");
8422        let report_link = format!("/runs/html/{}", pt.run_id);
8423        let branch_html = if branch.is_empty() {
8424            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
8425        } else {
8426            format!(
8427                "<span class=\"mc-card-branch\">{}</span>",
8428                html_escape(branch)
8429            )
8430        };
8431        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
8432        let tags_html = pt
8433            .git_tags
8434            .as_deref()
8435            .filter(|t| !t.is_empty())
8436            .map(|t| {
8437                let chips = t
8438                    .split(',')
8439                    .filter(|s| !s.is_empty())
8440                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
8441                    .collect::<Vec<_>>()
8442                    .join(" ");
8443                format!(
8444                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
8445                     <span class=\"mc-row-val\">{chips}</span></div>"
8446                )
8447            })
8448            .unwrap_or_default();
8449        let nearest = pt
8450            .git_nearest_tag
8451            .as_deref()
8452            .map(|t| format!("near {}", html_escape(t)))
8453            .unwrap_or_default();
8454        let arrow = if i < n - 1 && !is_many {
8455            "<div class='mc-arrow'>&#8594;</div>"
8456        } else {
8457            ""
8458        };
8459        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
8460        let nearest_html = if nearest.is_empty() {
8461            String::new()
8462        } else {
8463            format!(
8464                "<span class=\"mc-card-nearest-wrap\">\
8465                 <span class=\"mc-card-nearest\">{nearest}</span>\
8466                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
8467                 </span>"
8468            )
8469        };
8470        write!(
8471            scan_strip,
8472            r#"<div class="mc-card">
8473              <div class="mc-card-header">
8474                <div class="mc-card-num">Scan {num}</div>
8475                <div class="mc-card-project-col">
8476                  <div class="mc-card-project">{project_label}</div>
8477                  {scope_badge}
8478                </div>
8479              </div>
8480              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
8481              <div class="mc-card-rows">
8482                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
8483                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
8484                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
8485                <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>
8486                {tags_html}
8487              </div>
8488              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
8489            </div>{arrow}"#,
8490            num = i + 1,
8491            commit = html_escape(commit),
8492            commit_date = commit_date_html,
8493            ts_ms = ts_ms,
8494            code = fmt_num(pt.code_lines),
8495            scope_badge = scope_badge,
8496            nearest_html = nearest_html,
8497        )
8498        .unwrap();
8499    }
8500    scan_strip
8501}
8502
8503/// Build the metric progression table (thead + tbody) for multi-compare.
8504#[allow(clippy::too_many_lines)]
8505fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
8506    use std::fmt::Write as _;
8507    struct MetricRow<'a> {
8508        label: &'a str,
8509        values: Vec<i64>,
8510        seq_deltas: Vec<i64>,
8511        net_delta: i64,
8512    }
8513    let rows: Vec<MetricRow<'_>> = vec![
8514        MetricRow {
8515            label: "Code Lines",
8516            values: multi.points.iter().map(|p| p.code_lines).collect(),
8517            seq_deltas: multi
8518                .sequential_deltas
8519                .iter()
8520                .map(|d| d.summary.code_lines_delta)
8521                .collect(),
8522            net_delta: multi.total_delta.code_lines_delta,
8523        },
8524        MetricRow {
8525            label: "Files Analyzed",
8526            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
8527            seq_deltas: multi
8528                .sequential_deltas
8529                .iter()
8530                .map(|d| d.summary.files_analyzed_delta)
8531                .collect(),
8532            net_delta: multi.total_delta.files_analyzed_delta,
8533        },
8534        MetricRow {
8535            label: "Comment Lines",
8536            values: multi.points.iter().map(|p| p.comment_lines).collect(),
8537            seq_deltas: multi
8538                .sequential_deltas
8539                .iter()
8540                .map(|d| d.summary.comment_lines_delta)
8541                .collect(),
8542            net_delta: multi.total_delta.comment_lines_delta,
8543        },
8544        MetricRow {
8545            label: "Blank Lines",
8546            values: multi.points.iter().map(|p| p.blank_lines).collect(),
8547            seq_deltas: multi
8548                .sequential_deltas
8549                .iter()
8550                .map(|d| d.summary.blank_lines_delta)
8551                .collect(),
8552            net_delta: multi.total_delta.blank_lines_delta,
8553        },
8554        MetricRow {
8555            label: "Tests",
8556            values: multi.points.iter().map(|p| p.test_count).collect(),
8557            seq_deltas: multi
8558                .points
8559                .windows(2)
8560                .map(|pts| pts[1].test_count - pts[0].test_count)
8561                .collect(),
8562            net_delta: multi.points.last().map_or(0, |l| l.test_count)
8563                - multi.points.first().map_or(0, |f| f.test_count),
8564        },
8565    ];
8566    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
8567    for i in 0..n {
8568        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
8569        if i < n - 1 {
8570            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
8571        }
8572    }
8573    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
8574    let mut metrics_tbody = String::new();
8575    for row in &rows {
8576        metrics_tbody.push_str("<tr>");
8577        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
8578        for i in 0..n {
8579            write!(
8580                metrics_tbody,
8581                "<td class='mc-val-col'>{}</td>",
8582                fmt_comma(row.values[i])
8583            )
8584            .unwrap();
8585            if i < n - 1 {
8586                let d = row.seq_deltas[i];
8587                write!(
8588                    metrics_tbody,
8589                    "<td class='mc-delta-col {cls}'>{val}</td>",
8590                    cls = multi_delta_class(d),
8591                    val = multi_fmt_delta(d)
8592                )
8593                .unwrap();
8594            }
8595        }
8596        let nd = row.net_delta;
8597        write!(
8598            metrics_tbody,
8599            "<td class='mc-net-col {cls}'>{val}</td>",
8600            cls = multi_delta_class(nd),
8601            val = multi_fmt_delta(nd)
8602        )
8603        .unwrap();
8604        metrics_tbody.push_str("</tr>");
8605    }
8606    (metrics_thead, metrics_tbody)
8607}
8608
8609/// Build the JS-embeddable points JSON array for the multi-compare chart.
8610fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
8611    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
8612    for (i, pt) in multi.points.iter().enumerate() {
8613        let commit = pt.git_commit.as_deref().unwrap_or("");
8614        let branch = pt.git_branch.as_deref().unwrap_or("");
8615        let tags = pt.git_tags.as_deref().unwrap_or("");
8616        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
8617        let scanned_ms = pt.timestamp.timestamp_millis();
8618        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8619        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
8620        let commit_date = entry
8621            .and_then(|e| e.git_commit_date.as_deref())
8622            .and_then(fmt_git_date)
8623            .unwrap_or_default();
8624        let author = entry
8625            .and_then(|e| e.git_author.as_deref())
8626            .unwrap_or("")
8627            .to_string();
8628        let cov = pt
8629            .coverage_line_pct
8630            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
8631        parts.push(format!(
8632            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}}}"#,
8633            run_id = js_escape(&pt.run_id),
8634            commit = js_escape(commit),
8635            branch = js_escape(branch),
8636            tags = js_escape(tags),
8637            nearest = js_escape(nearest),
8638            commit_date = js_escape(&commit_date),
8639            author = js_escape(&author),
8640            scanned = js_escape(&scanned),
8641            code = pt.code_lines,
8642            comments = pt.comment_lines,
8643            blank = pt.blank_lines,
8644            files = pt.files_analyzed,
8645            tests = pt.test_count,
8646        ));
8647    }
8648    format!("[{}]", parts.join(","))
8649}
8650
8651/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
8652fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
8653    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
8654    for row in &multi.file_matrix {
8655        let lang = row.language.as_deref().unwrap_or("");
8656        let codes: Vec<String> = row
8657            .code_per_scan
8658            .iter()
8659            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8660            .collect();
8661        let deltas: Vec<String> = row
8662            .code_delta_per_scan
8663            .iter()
8664            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8665            .collect();
8666        parts.push(format!(
8667            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
8668            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
8669            status = row.overall_status,
8670            codes = codes.join(","),
8671            deltas = deltas.join(","),
8672            total = row.total_code_delta,
8673        ));
8674    }
8675    format!("[{}]", parts.join(","))
8676}
8677
8678/// Build the column header cells for the file-matrix table.
8679fn build_mc_file_col_headers(n: usize) -> String {
8680    use std::fmt::Write as _;
8681    let mut out = String::new();
8682    for i in 0..n {
8683        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
8684        if i < n - 1 {
8685            write!(
8686                out,
8687                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
8688                i + 2
8689            )
8690            .unwrap();
8691        }
8692    }
8693    out
8694}
8695
8696/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
8697fn build_mc_scope_bar(
8698    has_submodule_data: bool,
8699    sub_names: &[String],
8700    runs_csv: &str,
8701    active_sub: Option<&str>,
8702    super_scope_active: bool,
8703) -> String {
8704    use std::fmt::Write as _;
8705    if !has_submodule_data {
8706        return String::new();
8707    }
8708    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
8709    let full_active = active_sub.is_none() && !super_scope_active;
8710    let mut bar = format!(
8711        r#"<div class="submod-scope-bar">
8712  <span class="submod-scope-label">
8713    <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>
8714    Scope:
8715  </span>
8716  <div class="submod-scope-divider"></div>
8717  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
8718  <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>"#,
8719        full_cls = if full_active { " active" } else { "" },
8720        super_cls = if super_scope_active { " active" } else { "" },
8721    );
8722    for s in sub_names {
8723        let is_active = active_sub == Some(s.as_str());
8724        write!(
8725            bar,
8726            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
8727            cls = if is_active { " active" } else { "" },
8728            name_enc = html_escape(s),
8729            name_esc = html_escape(s),
8730        )
8731        .unwrap();
8732    }
8733    bar.push_str("\n</div>");
8734    bar
8735}
8736
8737/// Build the scope-description label shown in the page subtitle.
8738fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
8739    active_sub.map_or_else(
8740        || {
8741            if super_scope_active {
8742                "Super-repo only &mdash; ".to_string()
8743            } else {
8744                String::new()
8745            }
8746        },
8747        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
8748    )
8749}
8750
8751#[allow(clippy::too_many_lines)]
8752#[allow(clippy::too_many_arguments)]
8753fn multi_compare_page(
8754    multi: &MultiScanComparison,
8755    project_label: &str,
8756    version: &str,
8757    csp_nonce: &str,
8758    has_submodule_data: bool,
8759    sub_names: &[String],
8760    runs_csv: &str,
8761    super_scope_active: bool,
8762    active_sub: Option<&str>,
8763    entries: &[RegistryEntry],
8764) -> String {
8765    let n = multi.points.len();
8766    let is_many = n > 4;
8767    let mc_strip_class = if is_many {
8768        "mc-strip mc-strip-grid"
8769    } else {
8770        "mc-strip"
8771    };
8772
8773    // ── Scan strip cards ──────────────────────────────────────────────────────
8774    let scan_strip = build_mc_scan_strip(
8775        multi,
8776        entries,
8777        n,
8778        is_many,
8779        active_sub,
8780        super_scope_active,
8781        project_label,
8782    );
8783
8784    // ── Summary metrics table ─────────────────────────────────────────────────
8785    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
8786
8787    // ── Chart data and table helpers ──────────────────────────────────────────
8788    let points_json = build_mc_points_json(multi, entries);
8789    let file_matrix_json = build_mc_file_matrix_json(multi);
8790
8791    // Counts for filter tabs
8792    let files_modified = multi
8793        .file_matrix
8794        .iter()
8795        .filter(|f| f.overall_status == "modified")
8796        .count();
8797    let files_added = multi
8798        .file_matrix
8799        .iter()
8800        .filter(|f| f.overall_status == "added")
8801        .count();
8802    let files_removed = multi
8803        .file_matrix
8804        .iter()
8805        .filter(|f| f.overall_status == "removed")
8806        .count();
8807    let files_unchanged = multi
8808        .file_matrix
8809        .iter()
8810        .filter(|f| f.overall_status == "unchanged")
8811        .count();
8812    let total_files = multi.file_matrix.len();
8813
8814    let file_col_headers = build_mc_file_col_headers(n);
8815    let nav_compare_active = "";
8816    let scope_bar_html = build_mc_scope_bar(
8817        has_submodule_data,
8818        sub_names,
8819        runs_csv,
8820        active_sub,
8821        super_scope_active,
8822    );
8823    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
8824
8825    format!(
8826        r##"<!doctype html>
8827<html lang="en">
8828<head>
8829  <meta charset="utf-8">
8830  <meta name="viewport" content="width=device-width, initial-scale=1">
8831  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
8832  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8833  <style nonce="{csp_nonce}">
8834    :root{{--radius:18px;--bg:#f5efe8;--surface:#fbf7f2;--surface-2:#f4ede4;--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;}}
8835    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
8836    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
8837    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;}}
8838    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8839    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
8840    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8841    .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;}}
8842    @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));}}}}
8843    .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);}}
8844    .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;}}
8845    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
8846    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
8847    @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;}}}}
8848    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
8849    .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));}}
8850    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8851    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
8852    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
8853    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
8854    .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;}}
8855    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8856    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
8857    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8858    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8859    .nav-dropdown{{position:relative;display:inline-flex;}}
8860    .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;}}
8861    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8862    .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;}}
8863    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
8864    .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);}}
8865    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
8866    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
8867    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
8868    body:not(.dark-theme) .icon-sun{{display:none;}}
8869    body.dark-theme .icon-moon{{display:none;}}
8870    .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;}}
8871    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8872    .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);}}
8873    .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;}}
8874    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
8875    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
8876    .settings-modal-body{{padding:14px 16px 16px;}}
8877    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
8878    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8879    .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;}}
8880    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
8881    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
8882    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
8883    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
8884    .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;}}
8885    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8886    .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;}}
8887    .btn-back:hover{{background:var(--line);}}
8888    .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;}}
8889    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;}}
8890    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
8891    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
8892    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
8893    .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;}}
8894    .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;}}
8895    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
8896    .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;}}
8897    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
8898    body.dark-theme .mc-card{{background:var(--surface-2);}}
8899    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
8900    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
8901    .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%;}}
8902    .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;}}
8903    .mc-card-commit:hover{{color:var(--oxide);}}
8904    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
8905    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
8906    .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;}}
8907    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
8908    .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;}}
8909    .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;}}
8910    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
8911    .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;}}
8912    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
8913    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
8914    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
8915    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
8916    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
8917    .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);}}
8918    .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);}}
8919    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
8920    .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;}}
8921    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
8922    .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;}}
8923    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
8924    .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;}}
8925    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
8926    .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;}}
8927    .submod-scope-btn:hover{{background:var(--line);}}
8928    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8929    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
8930    .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;}}
8931    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
8932    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8933    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
8934    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
8935    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
8936    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
8937    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
8938    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
8939    .metrics-table .pos{{color:var(--pos);}}
8940    .metrics-table .neg{{color:var(--neg);}}
8941    .metrics-table .zero{{color:var(--muted);}}
8942    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
8943    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8944    .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;}}
8945    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8946    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
8947    .chart-wrap{{width:100%;overflow-x:auto;}}
8948    #mc-chart{{display:block;width:100%;}}
8949    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
8950    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
8951    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
8952    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
8953    .ic-card{{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}}
8954    body.dark-theme .ic-card{{border-color:var(--line-strong);}}
8955    .ic-card-h2{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}}
8956    .ic-card-h2-row{{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}}
8957    .ic-card-h2-row .ic-card-h2{{margin:0;}}
8958    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
8959    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
8960    .ic-cb{{cursor:pointer;transition:filter .15s;}}
8961    .ic-cb:hover{{filter:brightness(1.12);}}
8962    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
8963    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
8964    #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;}}
8965    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8966    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
8967    .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;}}
8968    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8969    .tab-btn:hover:not(.active){{background:var(--line);}}
8970    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
8971    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
8972    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
8973    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
8974    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
8975    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
8976    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
8977    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
8978    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
8979    .table-wrap{{width:100%;overflow-x:auto;}}
8980    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
8981    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
8982    #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;}}
8983    #file-table th.left,#file-table td.left{{text-align:left;}}
8984    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
8985    .file-delta-col{{color:var(--muted);font-size:11px;}}
8986    .file-net-col{{font-weight:800;}}
8987    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
8988    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
8989    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
8990    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
8991    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
8992    .status-badge.modified{{background:#fff2d8;color:#926000;}}
8993    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
8994    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
8995    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
8996    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
8997    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
8998    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
8999    tr.row-added td{{background:rgba(26,143,71,0.04);}}
9000    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9001    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9002    tr.row-unchanged td{{color:var(--muted);}}
9003    tr.row-unchanged .status-badge{{opacity:.65;}}
9004    .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;}}
9005    .absent{{color:var(--muted);font-style:italic;}}
9006    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9007    .pagination-info{{font-size:12px;color:var(--muted);}}
9008    .pagination-btns{{display:flex;gap:5px;}}
9009    .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;}}
9010    .pg-btn:hover:not(:disabled){{background:var(--line);}}
9011    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9012    .pg-btn:disabled{{opacity:.35;cursor:default;}}
9013    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;}}
9014    .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;}}
9015    .export-btn:hover{{background:var(--line);}}
9016    .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;}}
9017    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9018    .site-footer a{{color:var(--muted);}}
9019    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;}}
9020    body.pdf-mode{{background:#fff!important;}}
9021    .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;}}
9022    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9023    .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;}}
9024    .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;}}
9025    .mc-modal-title{{font-size:18px;font-weight:800;}}
9026    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9027    .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;}}
9028    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
9029    .mc-modal-body{{padding:18px 22px;}}
9030    .mc-modal-sec{{margin-bottom:20px;}}
9031    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
9032    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
9033    .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;}}
9034    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
9035    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
9036    .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;}}
9037    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
9038    .mc-modal-row:last-child{{border-bottom:none;}}
9039    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9040    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9041    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9042    .mc-modal-val a:hover{{text-decoration:underline;}}
9043    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9044    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9045    .mc-modal-stat[data-tip]{{cursor:help;}}
9046    #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);}}
9047    .mc-card{{cursor:pointer;}}
9048    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
9049  </style>
9050</head>
9051<body>
9052  <div class="background-watermarks" aria-hidden="true">
9053    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9054    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9055    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9056    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9057    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9058    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9059  </div>
9060  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9061  <div class="top-nav">
9062    <div class="top-nav-inner">
9063      <a class="brand" href="/">
9064        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9065        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
9066      </a>
9067      <div class="nav-right">
9068        <a class="nav-pill" href="/">Home</a>
9069        <div class="nav-dropdown">
9070          <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>
9071          <div class="nav-dropdown-menu">
9072            <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>
9073          </div>
9074        </div>
9075        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
9076        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9077        <div class="nav-dropdown">
9078          <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>
9079          <div class="nav-dropdown-menu">
9080            <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>
9081          </div>
9082        </div>
9083        <div class="server-status-wrap" id="server-status-wrap">
9084          <div class="nav-pill server-online-pill" id="server-status-pill">
9085            <span class="status-dot" id="status-dot"></span>
9086            <span id="server-status-label">Server</span>
9087            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9088          </div>
9089          <div class="server-status-tip">
9090            OxideSLOC is running &mdash; accessible on your network.
9091            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9092          </div>
9093        </div>
9094        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9095          <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>
9096        </button>
9097        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9098          <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>
9099          <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>
9100        </button>
9101      </div>
9102    </div>
9103  </div>
9104
9105  <div class="page">
9106    <!-- Hero header -->
9107    <div class="mc-hero">
9108      <div class="mc-hero-header">
9109        <div>
9110          <div class="mc-title">Multi-Scan Timeline</div>
9111          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
9112          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
9113        </div>
9114        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9115          <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>
9116          <div class="export-group" id="mc-top-export-group">
9117            <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>
9118            <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>
9119          </div>
9120        </div>
9121      </div>
9122      {scope_bar_html}
9123      <!-- Scan strip -->
9124      <div class="{mc_strip_class}">{scan_strip}</div>
9125    </div>
9126
9127    <!-- Summary metrics table -->
9128    <div class="panel">
9129      <div class="panel-title">Metric Progression</div>
9130      <div class="table-wrap">
9131        <table class="metrics-table">
9132          <thead>{metrics_thead}</thead>
9133          <tbody>{metrics_tbody}</tbody>
9134        </table>
9135      </div>
9136    </div>
9137
9138    <!-- Scan Charts -->
9139    <div class="panel" id="mc-charts-panel">
9140      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
9141      <div class="ic-grid">
9142        <!-- Timeline line chart — spans full width -->
9143        <div class="ic-card" style="grid-column:span 2">
9144          <div class="ic-card-h2-row">
9145            <span class="ic-card-h2">Timeline</span>
9146            <div class="chart-toolbar" style="margin:0">
9147              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
9148              <button class="chart-metric-btn" data-metric="files">Files</button>
9149              <button class="chart-metric-btn" data-metric="comments">Comments</button>
9150              <button class="chart-metric-btn" data-metric="tests">Tests</button>
9151              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
9152            </div>
9153          </div>
9154          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
9155        </div>
9156        <!-- Code Metrics: Scan 1 vs Latest -->
9157        <div class="ic-card">
9158          <div class="ic-card-h2">Code Metrics &mdash; Scan 1 vs Latest</div>
9159          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;scan&nbsp;1)</span></div>
9160          <div id="mc-ic-c1"></div>
9161        </div>
9162        <!-- Language Code Delta -->
9163        <div class="ic-card" id="mc-ic-lang-card">
9164          <div class="ic-card-h2">Language Code Delta</div>
9165          <div id="mc-ic-c3"></div>
9166        </div>
9167        <!-- Delta by Metric -->
9168        <div class="ic-card">
9169          <div class="ic-card-h2">Delta by Metric</div>
9170          <div id="mc-ic-c2"></div>
9171        </div>
9172        <!-- File Change Distribution -->
9173        <div class="ic-card">
9174          <div class="ic-card-h2">File Change Distribution</div>
9175          <div id="mc-ic-c4"></div>
9176        </div>
9177      </div>
9178    </div>
9179
9180    <!-- File matrix table -->
9181    <div class="panel">
9182      <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>
9183      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
9184        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
9185          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
9186          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
9187          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
9188          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
9189          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
9190        </div>
9191        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9192          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
9193          <div class="export-group">
9194          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
9195          <button type="button" class="export-btn" id="export-csv-btn">
9196            <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>
9197            CSV
9198          </button>
9199          <button type="button" class="export-btn" id="mc-file-xls-btn">
9200            <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>
9201            Excel
9202          </button>
9203          </div>
9204        </div>
9205      </div>
9206      <div class="table-wrap">
9207        <table id="file-table">
9208          <thead>
9209            <tr>
9210              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
9211              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
9212              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
9213              {file_col_headers}
9214              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
9215            </tr>
9216          </thead>
9217          <tbody id="file-tbody"></tbody>
9218        </table>
9219      </div>
9220      <div class="pagination">
9221        <span class="pagination-info" id="pg-info"></span>
9222        <div class="pagination-btns" id="pg-btns"></div>
9223        <div style="display:flex;align-items:center;gap:6px;">
9224          <span style="font-size:12px;color:var(--muted)">Show</span>
9225          <select class="per-page" id="per-page-sel">
9226            <option value="25" selected>25 per page</option>
9227            <option value="50">50 per page</option>
9228            <option value="100">100 per page</option>
9229          </select>
9230        </div>
9231      </div>
9232    </div>
9233  </div>
9234
9235  <div id="mc-ic-tt"></div>
9236
9237  <footer class="site-footer">
9238    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
9239    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9240    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9241    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9242    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9243  </footer>
9244
9245  <script nonce="{csp_nonce}">
9246  (function(){{
9247    // ── Dark theme ───────────────────────────────────────────────────────────
9248    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
9249    var tt=document.getElementById('theme-toggle');
9250    if(tt)tt.addEventListener('click',function(){{
9251      var on=document.body.classList.toggle('dark-theme');
9252      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
9253      renderChart(activeMetric);
9254    }});
9255
9256    // ── Code particles ───────────────────────────────────────────────────────
9257    var container=document.getElementById('code-particles');
9258    if(container){{
9259      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()'];
9260      for(var i=0;i<28;i++){{
9261        (function(idx){{
9262          var el=document.createElement('span');el.className='code-particle';
9263          el.textContent=snips[idx%snips.length];
9264          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
9265          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
9266          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
9267          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
9268          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
9269          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
9270          container.appendChild(el);
9271        }})(i);
9272      }}
9273    }}
9274
9275    // ── Watermarks ───────────────────────────────────────────────────────────
9276    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9277    if(wms.length){{
9278      var placed=[];
9279      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;}}
9280      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];}}
9281      var half=Math.floor(wms.length/2);
9282      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;}});
9283    }}
9284
9285    // ── Settings / colour scheme modal ───────────────────────────────────────
9286    (function(){{
9287      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'}}];
9288      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);}});}}
9289      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
9290      function init(){{
9291        var btn=document.getElementById('settings-btn');if(!btn)return;
9292        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9293        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>';
9294        document.body.appendChild(m);
9295        var g=document.getElementById('scheme-grid');
9296        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);}});
9297        var cl=document.getElementById('settings-close-btn');
9298        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');}});
9299        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9300        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9301      }}
9302      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9303    }})();
9304
9305    // ── Timezone support for scan timestamps ─────────────────────────────────
9306    (function(){{
9307      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';}};
9308      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'';}}}};
9309      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);}});}};
9310      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
9311      window.applyTz(storedTz);
9312      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
9313      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
9314    }})();
9315
9316    // ── Data ────────────────────────────────────────────────────────────────
9317    var POINTS={points_json};
9318    var FILES={file_matrix_json};
9319    var N={n};
9320
9321    // ── fmt helper ───────────────────────────────────────────────────────────
9322    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();}}
9323    function fmtFull(n){{return Number(n).toLocaleString();}}
9324    function fmtDelta(n){{return n>0?'+'+fmt(n):fmt(n);}}
9325
9326    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
9327    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
9328    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));}}
9329    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
9330    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
9331
9332    // ── Timeline chart ───────────────────────────────────────────────────────
9333    var activeMetric='code';
9334    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
9335    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
9336
9337    function renderChart(metric){{
9338      var svg=document.getElementById('mc-chart');if(!svg)return;
9339      var W=svg.getBoundingClientRect().width||800,H=280;
9340      svg.setAttribute('height',H);
9341      var pad={{l:62,r:20,t:32,b:72}};
9342      var dark=document.body.classList.contains('dark-theme');
9343      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
9344      var valid=pts.filter(function(v){{return v!=null;}});
9345      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;}}
9346      var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
9347      if(minV===maxV){{minV=Math.max(0,minV-1);maxV=maxV+1;}}
9348      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
9349      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
9350      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
9351      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
9352      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
9353      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
9354      var parts=[];
9355      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
9356      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>');}}
9357      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
9358      var lineD='';var firstPt=true;
9359      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);}}}}
9360      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
9361      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
9362      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
9363      for(var i=0;i<N;i++){{
9364        if(pts[i]==null)continue;
9365        var cx=xOf(i),cy=yOf(pts[i]);
9366        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
9367        var hasTag=p.tags&&p.tags.length>0;
9368        // Permanent Y-value label above the dot
9369        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmt(pts[i])+'</text>');
9370        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" onclick="window.location=\'/runs/report.html/'+p.run_id+'\'"/>');
9371        var xanchor=i===0?'start':i===N-1?'end':'middle';
9372        // X-axis label at 2× the original size (18 px)
9373        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>');
9374      }}
9375      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
9376      svg.setAttribute('viewBox','0 0 '+W+' '+H);
9377      svg.innerHTML=parts.join('');
9378      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
9379      svg.onmousemove=function(e){{
9380        var rect=svg.getBoundingClientRect();
9381        var scaleX=W/rect.width;
9382        var mouseX=(e.clientX-rect.left)*scaleX;
9383        var nearest=-1,minDist=Infinity;
9384        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;}}}}
9385        if(nearest<0)return;
9386        var nc=xOf(nearest),ny=yOf(pts[nearest]);
9387        var xhair=svg.querySelector('.mc-xhair');
9388        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
9389        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"/>';
9390        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
9391        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
9392        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>';
9393        var bx=rect.left+(nc/W*rect.width)+18;
9394        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
9395        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
9396      }};
9397      svg.onmouseleave=function(){{
9398        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
9399        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
9400      }};
9401    }}
9402
9403    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9404
9405    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
9406      btn.addEventListener('click',function(){{
9407        activeMetric=this.dataset.metric;
9408        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
9409        this.classList.add('active');
9410        renderChart(activeMetric);
9411      }});
9412    }});
9413    if(typeof ResizeObserver!=='undefined'){{
9414      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
9415    }}
9416    renderChart(activeMetric);
9417
9418    // ── File matrix table ────────────────────────────────────────────────────
9419    var activeStatus='';
9420    var currentPage=1;
9421    var perPage=25;
9422    var mcSortCol=null,mcSortAsc=true;
9423
9424    function getFiltered(){{
9425      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
9426      if(!mcSortCol)return data;
9427      var asc=mcSortAsc;
9428      return data.slice().sort(function(a,b){{
9429        var va,vb;
9430        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
9431        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
9432        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
9433        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
9434        else{{return 0;}}
9435        if(asc)return va<vb?-1:va>vb?1:0;
9436        return va<vb?1:va>vb?-1:0;
9437      }});
9438    }}
9439
9440    function renderFilePage(){{
9441      var filtered=getFiltered();
9442      var total=filtered.length;
9443      var totalPages=Math.max(1,Math.ceil(total/perPage));
9444      if(currentPage>totalPages)currentPage=totalPages;
9445      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
9446      var tbody=document.getElementById('file-tbody');if(!tbody)return;
9447      var rows=[];
9448      for(var i=start;i<end;i++){{
9449        var f=filtered[i];
9450        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
9451        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
9452        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
9453        for(var j=0;j<N;j++){{
9454          var cv=f.c[j];
9455          cells+='<td class="file-scan-col">'+(cv!=null?fmt(cv):'<span class="absent">\u2014</span>')+'</td>';
9456          if(j<N-1){{
9457            var dv=f.d[j+1];
9458            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
9459              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
9460          }}
9461        }}
9462        var tc=f.t;
9463        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
9464        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
9465      }}
9466      tbody.innerHTML=rows.join('');
9467
9468      var info=document.getElementById('pg-info');
9469      if(info)info.textContent='Showing '+(total?start+1:0)+'–'+end+' of '+total+' files';
9470      renderPgBtns(totalPages);
9471    }}
9472
9473    function renderPgBtns(totalPages){{
9474      var wrap=document.getElementById('pg-btns');if(!wrap)return;
9475      var btns=[];
9476      function mkBtn(label,page,active,disabled){{
9477        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
9478        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
9479      }}
9480      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
9481      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
9482      if(s>1)btns.push(mkBtn('1',1,false,false));
9483      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
9484      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
9485      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
9486      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
9487      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
9488      wrap.innerHTML=btns.join('');
9489      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
9490        b.addEventListener('click',function(){{
9491          var pg=parseInt(this.dataset.pg,10);
9492          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
9493        }});
9494      }});
9495    }}
9496
9497    // Tab filter
9498    document.querySelectorAll('.tab-btn').forEach(function(btn){{
9499      btn.addEventListener('click',function(){{
9500        activeStatus=this.dataset.status||'';
9501        currentPage=1;
9502        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9503        this.classList.add('active');
9504        renderFilePage();
9505      }});
9506    }});
9507
9508    // Per-page selector
9509    var ppSel=document.getElementById('per-page-sel');
9510    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
9511
9512    // ── Column header sort ───────────────────────────────────────────────────
9513    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
9514      th.addEventListener('click',function(){{
9515        var col=th.dataset.sortCol;
9516        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
9517        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9518          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
9519        }});
9520        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
9521        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
9522        currentPage=1;renderFilePage();
9523      }});
9524    }});
9525
9526    // Reset button also clears sort
9527    var mcResetBtn=document.getElementById('mc-file-reset-btn');
9528    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
9529      mcSortCol=null;mcSortAsc=true;
9530      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9531        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
9532      }});
9533      activeStatus='';currentPage=1;
9534      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9535      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
9536      renderFilePage();
9537    }});
9538
9539    renderFilePage();
9540
9541    // ── CSV export ───────────────────────────────────────────────────────────
9542    var exportBtn=document.getElementById('export-csv-btn');
9543    if(exportBtn)exportBtn.addEventListener('click',function(){{
9544      var header=['File','Language','Status'];
9545      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
9546      header.push('Net Delta');
9547      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
9548      var filtered=getFiltered();
9549      filtered.forEach(function(f){{
9550        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
9551        for(var j=0;j<N;j++){{
9552          cols.push(f.c[j]!=null?f.c[j]:'');
9553          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
9554        }}
9555        cols.push(f.t);
9556        rows.push(cols.join(','));
9557      }});
9558      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
9559      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9560      a.download=mcExportName('csv');a.click();
9561    }});
9562
9563    // ── File matrix extra export buttons ─────────────────────────────────────
9564    (function(){{
9565      var resetBtn=document.getElementById('mc-file-reset-btn');
9566      if(resetBtn)resetBtn.addEventListener('click',function(){{
9567        activeStatus='';currentPage=1;
9568        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9569        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
9570        renderFilePage();
9571      }});
9572
9573      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
9574      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
9575      function mcMakeXlsx(fname){{
9576        var filtered=getFiltered();
9577        var enc=new TextEncoder();
9578        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;}}
9579        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;}}
9580        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
9581        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
9582        var ss=[],si={{}};
9583        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
9584        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9585        function WS(){{
9586          var R=0,buf=[];
9587          function cl(c){{return String.fromCharCode(65+c);}}
9588          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
9589          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
9590          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
9591          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>';}}
9592          return{{sc:sc,nc:nc,row:row,xml:xml}};
9593        }}
9594        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
9595        var proj=mcExportProj();
9596        // \u2500\u2500 Summary sheet \u2500\u2500
9597        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9598        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
9599        r1(s1(0,proj,2));
9600        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
9601        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
9602        r1('');
9603        r1(s1(0,'SCAN SUMMARY',8));
9604        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));
9605        POINTS.forEach(function(p,i){{
9606          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
9607          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));
9608        }});
9609        r1('');
9610        if(POINTS.length>1){{
9611          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
9612          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
9613          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
9614          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)));}};
9615          nr('Code Lines',pf.code,pl.code);
9616          nr('Comment Lines',pf.comments,pl.comments);
9617          nr('Files Analyzed',pf.files,pl.files);
9618          nr('Tests',pf.tests,pl.tests);
9619          r1('');
9620        }}
9621        var cMod=0,cAdd=0,cRem=0,cUnch=0;
9622        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
9623        var totF=FILES.length||1;
9624        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
9625        r1(s1(0,'FILE CHANGES',8));
9626        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
9627        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
9628        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
9629        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
9630        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
9631        var lm={{}};
9632        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;}});
9633        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
9634        if(langs.length){{
9635          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9636          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
9637          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)));}});
9638        }}
9639        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
9640        // \u2500\u2500 File Delta sheet \u2500\u2500
9641        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9642        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
9643        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);}}
9644        hcells+=s2(hc,'Net Delta',3);
9645        r2(hcells);
9646        filtered.forEach(function(f){{
9647          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
9648          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));}}}}
9649          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
9650          r2(cells);
9651        }});
9652        var ncols=3+N+(N-1)+1;
9653        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
9654        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>';
9655        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9656        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>',
9657          '_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>',
9658          '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>',
9659          '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>',
9660          '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>',
9661          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
9662        var zparts=[],zcds=[],zoff=0,znf=0;
9663        ['[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){{
9664          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9665          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]);
9666          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);
9667          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));
9668          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
9669          zoff+=entry.length;znf++;
9670        }});
9671        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
9672        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]);
9673        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
9674        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9675        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9676        out.set(new Uint8Array(eocd),pos);
9677        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
9678        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9679      }}
9680
9681      var xlsBtn=document.getElementById('mc-file-xls-btn');
9682      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
9683
9684      // File matrix HTML export — interactive: sort by column, filter by status
9685      function mcFileBuildHtml(){{
9686        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9687        var hdrs=['File','Language','Status'];
9688        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
9689        hdrs.push('Net \u0394');
9690        var SI=2;
9691        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;}});
9692        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
9693        var cnt={{all:allRows.length}};
9694        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
9695        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
9696        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
9697          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9698          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9699          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
9700          '.sub{{font-size:12px;color:#99aabb;}}'+
9701          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
9702          '.wr{{padding:16px 20px;}}'+
9703          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
9704          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
9705          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
9706          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
9707          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
9708          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
9709          'thead tr{{background:#1a2035;}}'+
9710          '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;}}'+
9711          'th:hover{{background:#2a3050;}}'+
9712          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
9713          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
9714          'tr:nth-child(even) td{{background:#faf7f4;}}'+
9715          'tr:hover td{{background:#f5f0ea;}}'+
9716          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
9717          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
9718        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
9719        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
9720          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
9721          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
9722          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
9723          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
9724        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
9725          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
9726          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
9727          '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>";}}'+
9728          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
9729          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
9730          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
9731          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
9732          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
9733          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
9734          '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("")'+
9735          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
9736          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
9737          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
9738          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
9739          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
9740          'sd=(sc===ci)?-sd:1;sc=ci;'+
9741          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
9742          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
9743          'render();';
9744        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
9745          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
9746          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
9747          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
9748          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
9749          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
9750          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
9751          '<script>'+inlineJs+'<\/script><\/body><\/html>';
9752      }}
9753
9754      var htmlBtn=document.getElementById('mc-file-html-btn');
9755      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
9756        var h=mcFileBuildHtml();
9757        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
9758        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9759        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9760      }});
9761
9762      var pdfBtn=document.getElementById('mc-file-pdf-btn');
9763      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
9764        var btn=pdfBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
9765        var h=mcBuildPdfHtml();
9766        fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:h,filename:mcExportName('files.pdf')}})}})
9767          .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
9768          .then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('files.pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
9769          .catch(function(e){{alert('PDF export failed: '+e.message);}})
9770          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
9771      }});
9772    }})();
9773
9774    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
9775    (function(){{
9776      var OX='#C45C10',GN='#2A6846',RD='#B23030',LGY='#DDDDDD';
9777      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9778      function fmt2(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();}}
9779      function px(n){{return Math.round(n);}}
9780      var _tt=document.getElementById('mc-ic-tt');
9781      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
9782      function addTT(el){{
9783        if(!el)return;
9784        el.addEventListener('mouseover',function(e){{
9785          var t=e.target.closest('[data-ttl]');
9786          if(t&&_tt){{
9787            var ttl=t.getAttribute('data-ttl');
9788            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
9789            _tt.style.display='block';mvTT(e);
9790            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9791            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
9792          }} else {{
9793            if(_tt)_tt.style.display='none';
9794            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9795          }}
9796        }});
9797        el.addEventListener('mouseleave',function(){{
9798          if(_tt)_tt.style.display='none';
9799          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9800        }});
9801        el.addEventListener('mousemove',function(e){{mvTT(e);}});
9802      }}
9803      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';}}
9804      if(N<2)return;
9805      var p0=POINTS[0],pLast=POINTS[N-1];
9806      // Chart 1: Code Metrics — Scan 1 vs Latest (grouped bars, same structure as Scan Delta)
9807      var c1mets=[
9808        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:'#93C5FD',cc:'#2563EB'}},
9809        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:'#C4B5FD',cc:'#7C3AED'}},
9810        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:'#6EE7B7',cc:'#0D9488'}}
9811      ];
9812      var maxV1=Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))*1.15||1;
9813      var C1W=620,C1H=196,c1mt=38,c1mb=30,c1ml=56,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
9814      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9815      for(var gi=1;gi<=4;gi++){{
9816        var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
9817        c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
9818        c1+='<text x="'+(c1ml-5)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">'+fmt2(gv)+'</text>';
9819      }}
9820      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9821      c1+='<text x="'+(c1ml-5)+'" y="'+(c1mt+c1ph+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">0</text>';
9822      c1mets.forEach(function(m,i){{
9823        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9824        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9825        c1+='<text x="'+cx+'" y="17" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="13" font-weight="700" fill="#444">'+esc(m.l)+'</text>';
9826        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="3" style="cursor:pointer;"/>';
9827        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.bc+'">'+fmt2(m.b)+'</text>';
9828        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="3" style="cursor:pointer;"/>';
9829        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.cc+'">'+fmt2(m.c)+'</text>';
9830        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="500" fill="#888">Scan 1</text>';
9831        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.cc+'">Latest</text>';
9832      }});
9833      c1+='</svg>';
9834      // Chart 2: Delta by Metric (net delta first scan to last)
9835      var mets=[
9836        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#2563EB'}},
9837        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#7C3AED'}},
9838        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:'#0D9488'}}
9839      ];
9840      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
9841      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;
9842      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9843      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9844      mets.forEach(function(m,i){{
9845        var y=16+i*rH,bw=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+fmt2(m.v);
9846        c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="13" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
9847        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;"/>';
9848        if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
9849        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="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}}
9850      }});
9851      c2+='</svg>';
9852      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
9853      var lm={{}};
9854      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;}});
9855      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
9856      var c3='';
9857      if(langs.length){{
9858        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
9859        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;
9860        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9861        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9862        langs.forEach(function(l,i){{
9863          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
9864          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9865          c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
9866          if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
9867          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,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}}
9868          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
9869        }});
9870        c3+='</svg>';
9871      }}
9872      // Chart 4: File Change Distribution (centered donut, legend below)
9873      var fm=0,fa=0,fr=0,fu=0;
9874      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
9875      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:'#CCCCCC'}}].filter(function(s){{return s.v>0;}});
9876      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
9877      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
9878      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">',ang4=-Math.PI/2;
9879      if(segs.length===1){{
9880        c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
9881        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface-2)"/>';
9882      }} else {{
9883        segs.forEach(function(s){{
9884          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
9885          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);
9886          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);
9887          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="white" stroke-width="2.5"/>';
9888          ang4+=sw;
9889        }});
9890      }}
9891      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt2(tot4)+'</text>';
9892      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9893      segs.forEach(function(s,i){{
9894        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
9895        c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
9896        c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt2(s.v)+'</text>';
9897      }});
9898      c4+='</svg>';
9899      // Inject charts
9900      var e1=document.getElementById('mc-ic-c1');if(e1){{e1.innerHTML=c1;addTT(e1);}}
9901      var e2=document.getElementById('mc-ic-c2');if(e2){{e2.innerHTML=c2;addTT(e2);}}
9902      var e3=document.getElementById('mc-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);}}
9903      var e4=document.getElementById('mc-ic-c4');if(e4){{e4.innerHTML=c4;addTT(e4);}}
9904      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
9905
9906      // HTML legend hover → highlight matching SVG bars within the SAME card only
9907      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
9908        var metric=leg.getAttribute('data-highlight');
9909        var parentCard=leg.closest('.ic-card');
9910        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
9911        if(!chartEl)return;
9912        leg.addEventListener('mouseenter',function(){{
9913          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
9914            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
9915              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
9916              x.style.opacity='1';
9917            }} else {{
9918              x.style.opacity='0.28';
9919            }}
9920          }});
9921        }});
9922        leg.addEventListener('mouseleave',function(){{
9923          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9924        }});
9925      }});
9926      // Author handles
9927      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
9928
9929      // ── Export helpers ────────────────────────────────────────────────────────
9930      // Fetch one image from the server and return a data-URI Promise
9931      function mcFetchUri(path){{
9932        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
9933          return new Promise(function(res){{
9934            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
9935          }});
9936        }}).catch(function(){{return '';}});
9937      }}
9938      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
9939      function mcInlineImgs(html,cb){{
9940        var paths=[],seen={{}};
9941        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
9942        if(!paths.length){{cb(html);return;}}
9943        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
9944          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
9945          .catch(function(){{cb(html);}});
9946      }}
9947      // Capture full-page HTML with all table rows visible
9948      function mcRawHtml(pdfMode){{
9949        if(pdfMode)document.body.classList.add('pdf-mode');
9950        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
9951        var html=document.documentElement.outerHTML;
9952        perPage=s;currentPage=p;renderFilePage();
9953        if(pdfMode)document.body.classList.remove('pdf-mode');
9954        return html;
9955      }}
9956
9957      // HTML export (full page with inlined images)
9958      function mcDoHtml(btn,fname){{
9959        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
9960        mcInlineImgs(mcRawHtml(false),function(html){{
9961          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
9962          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9963          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9964          btn.disabled=false;btn.innerHTML=orig;
9965        }});
9966      }}
9967      // PDF export — comprehensive document-style report: full numbers, all sections
9968      function mcBuildPdfHtml(){{
9969        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9970        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
9971        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
9972        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>';}}
9973        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
9974        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
9975        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)));}}
9976        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
9977        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
9978        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
9979        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
9980          '.hdr{{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9981          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9982          '.title{{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}}'+
9983          '.proj{{font-size:12px;color:#99aabb;margin-top:3px;}}'+
9984          '.hr{{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}}'+
9985          '.body{{padding:18px 24px;}}'+
9986          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px;}}'+
9987          '.sc{{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}}'+
9988          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
9989          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
9990          '.sec{{margin-bottom:20px;}}'+
9991          '.sh{{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}}'+
9992          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
9993          'th{{background:#1a2035;color:#fff;padding:5px 8px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;}}'+
9994          'td{{border-bottom:1px solid #eee;padding:4px 8px;vertical-align:middle;}}'+
9995          'tr:nth-child(even) td{{background:#faf8f6;}}'+
9996          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:20px;}}';
9997        // ── Metric Progression ────────────────────────────────────────────────
9998        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
9999        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
10000        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>';
10001        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
10002        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
10003        var progRows=POINTS.map(function(pt,i){{
10004          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)));
10005          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
10006            '<td style="text-align:right">'+full(pt.code)+'</td>'+
10007            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
10008            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
10009            '<td style="text-align:right">'+full(pt.files)+'</td>';
10010          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
10011          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
10012          return r+'</tr>';
10013        }}).join('');
10014        // ── Scan-to-scan changes ──────────────────────────────────────────────
10015        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
10016          var prev=POINTS[i];
10017          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
10018          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
10019          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
10020            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
10021            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
10022            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
10023            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
10024        }}).join(''):'';
10025        // ── File matrix (top 50 by |total delta|) ────────────────────────────
10026        var fmSection='';
10027        if(FILES&&FILES.length){{
10028          // Hard cap on per-scan columns so the table never overflows the page width.
10029          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
10030          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
10031          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
10032          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
10033          fmHdr+='<th style="text-align:right">Total \u0394</th>';
10034          var fmRows=topFiles.map(function(f){{
10035            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
10036            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>';
10037            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
10038            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
10039            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>';
10040          }}).join('');
10041          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
10042          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
10043            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
10044        }}
10045        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
10046          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
10047          '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Multi-Scan Timeline</div><div class="proj">{project_label}</div></div>'+
10048          '<div class="hr">{n} scans<br><span style="color:#7a8b9c">'+commitsList+'</span><br>Generated: '+esc(now)+'</div></div>'+
10049          '<div class="body">'+
10050          '<div class="sg">'+
10051          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
10052            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
10053          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
10054            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
10055          (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>':
10056            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
10057          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
10058          '</div>'+
10059          '<div class="sec"><p class="sh">Metric Progression</p>'+
10060          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
10061          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
10062          '<table><thead><tr><th style="text-align:center">Scans</th>'+
10063          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
10064          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
10065          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
10066          fmSection+
10067          '</div>'+
10068          '<div class="ftr"><span>oxide-sloc v{version}</span><span>Multi-Scan Timeline Report</span><span>{project_label} &middot; {n} scans</span></div>'+
10069          '</body></html>';
10070      }}
10071      function mcDoPdf(btn){{
10072        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
10073        var html=mcBuildPdfHtml();
10074        fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:html,filename:mcExportName('pdf')}})}})
10075          .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
10076          .then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
10077          .catch(function(e){{alert('PDF export failed: '+e.message);}})
10078          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
10079      }}
10080
10081      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
10082      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
10083      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
10084      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
10085      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
10086      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
10087      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
10088      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
10089      if(location.protocol==='file:'){{
10090        [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';}}}} );
10091        [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';}}}} );
10092      }}
10093    }})();
10094    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
10095    (function(){{
10096      function $(id){{return document.getElementById(id);}}
10097      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10098      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
10099      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
10100      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
10101      function openModal(idx){{
10102        var ov=$('mc-modal-overlay');if(!ov)return;
10103        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
10104        if(idx<0||idx>=N)return;
10105        var pt=POINTS[idx];
10106        titleEl.textContent='Scan '+(idx+1);
10107        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
10108        subEl.textContent=lbl;
10109        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
10110          '<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>'+
10111          '<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>'+
10112          '<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>'+
10113          '<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>'+
10114          (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>':'')+
10115          (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>':'')+
10116          '</div></div>';
10117        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
10118          (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>':'')+
10119          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
10120          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
10121          (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>':'')+
10122          (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>':'')+
10123          (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>':'')+
10124          (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>':'')+
10125          '<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>'+
10126          '</div>';
10127        var dHtml='';
10128        if(idx>0){{
10129          var prev=POINTS[idx-1];
10130          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
10131          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
10132            '<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>'+
10133            '<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>'+
10134            '<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>'+
10135            '</div></div>';
10136        }}
10137        bodyEl.innerHTML=sHtml+iHtml+dHtml;
10138        ov.classList.add('open');document.body.style.overflow='hidden';
10139      }}
10140      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
10141      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
10142      document.addEventListener('click',function(e){{
10143        if(!e.target||!e.target.closest)return;
10144        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
10145        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
10146        var card=e.target.closest('.mc-card');
10147        if(!card)return;
10148        if(e.target.closest('a'))return;
10149        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
10150        var i=cards.indexOf(card);
10151        if(i>=0)openModal(i);
10152      }});
10153      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
10154      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
10155      var statTip=null;
10156      document.addEventListener('mousemove',function(e){{
10157        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
10158        if(!box){{if(statTip)statTip.style.display='none';return;}}
10159        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
10160        var tip=box.getAttribute('data-tip')||'';
10161        if(statTip.textContent!==tip)statTip.textContent=tip;
10162        statTip.style.display='block';
10163        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
10164        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
10165        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
10166        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
10167      }});
10168      (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');}})();
10169    }})();
10170  }})();
10171  </script>
10172  <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]';
10173  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;}}
10174  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>
10175  <!-- Scan card detail modal -->
10176  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
10177    <div class="mc-modal" id="mc-modal">
10178      <div class="mc-modal-head">
10179        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
10180        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
10181      </div>
10182      <div class="mc-modal-body" id="mc-modal-body"></div>
10183    </div>
10184  </div>
10185</body>
10186</html>"##,
10187        project_label = html_escape(project_label),
10188        n = n,
10189        scan_strip = scan_strip,
10190        mc_strip_class = mc_strip_class,
10191        metrics_thead = metrics_thead,
10192        metrics_tbody = metrics_tbody,
10193        file_col_headers = file_col_headers,
10194        total_files = total_files,
10195        files_modified = files_modified,
10196        files_added = files_added,
10197        files_removed = files_removed,
10198        files_unchanged = files_unchanged,
10199        points_json = points_json,
10200        file_matrix_json = file_matrix_json,
10201        nav_compare_active = nav_compare_active,
10202        version = version,
10203        csp_nonce = csp_nonce,
10204        scope_bar_html = scope_bar_html,
10205        scope_label = scope_label,
10206    )
10207}
10208
10209// ── Trend report page ─────────────────────────────────────────────────────────
10210// Protected. Interactive time-series chart page that loads scan history via
10211// /api/metrics/history and renders a vanilla-SVG line chart.
10212//
10213// GET /trend-reports
10214
10215#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
10216async fn trend_report_handler(
10217    State(state): State<AppState>,
10218    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10219) -> Response {
10220    auto_scan_watched_dirs(&state).await;
10221
10222    let watched_dirs_list: Vec<String> = {
10223        let wd = state.watched_dirs.lock().await;
10224        wd.dirs.iter().map(|p| p.display().to_string()).collect()
10225    };
10226
10227    // Collect distinct project roots for the root selector dropdown.
10228    let roots: Vec<String> = {
10229        let reg = state.registry.lock().await;
10230        let mut seen = std::collections::BTreeSet::new();
10231        reg.entries
10232            .iter()
10233            .flat_map(|e| e.input_roots.iter().cloned())
10234            .filter(|r| seen.insert(r.clone()))
10235            .collect()
10236    };
10237
10238    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
10239    let nonce = &csp_nonce;
10240    let version = env!("CARGO_PKG_VERSION");
10241
10242    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
10243    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
10244    // of interactive controls — folder watching is managed by the host administrator.
10245    let watched_dirs_html: String = if state.server_mode {
10246        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()
10247    } else {
10248        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
10249            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
10250                .to_string()
10251        } else {
10252            watched_dirs_list
10253                .iter()
10254                .fold(String::new(), |mut s, d| {
10255                    use std::fmt::Write as _;
10256                    let escaped =
10257                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
10258                    write!(
10259                        s,
10260                        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>"#
10261                    ).expect("write to String is infallible");
10262                    s
10263                })
10264        };
10265        format!(
10266            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>"#
10267        )
10268    };
10269
10270    let html = format!(
10271        r##"<!doctype html>
10272<html lang="en">
10273<head>
10274  <meta charset="utf-8" />
10275  <meta name="viewport" content="width=device-width, initial-scale=1" />
10276  <title>OxideSLOC | Trend Reports</title>
10277  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10278  <style nonce="{nonce}">
10279    :root {{
10280      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10281      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10282      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
10283      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10284      --info-bg:#eef3ff; --info-text:#4467d8;
10285    }}
10286    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
10287    *{{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;}}
10288    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10289    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
10290    .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;}}
10291    @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));}}}}
10292    .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);}}
10293    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
10294    .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));}}
10295    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10296    .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;}}
10297    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
10298    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
10299    @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; }} }}
10300    .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;}}
10301    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10302    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10303    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10304    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
10305    .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;}}
10306    .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;}}
10307    .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;}}
10308    .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;}}
10309    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10310    .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);}}
10311    .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;}}
10312    .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;}}
10313    .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;}}
10314    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10315    .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;}}
10316    .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);}}
10317    .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;}}
10318    .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;}}
10319    .tz-select:focus{{border-color:var(--oxide);}}
10320    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10321    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
10322    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
10323    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
10324    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
10325    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
10326    .trend-title-block{{flex:1;min-width:0;}}
10327    .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;}}
10328    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
10329    .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;}}
10330    .chart-select:focus{{border-color:var(--accent);}}
10331    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
10332    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
10333    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
10334    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
10335    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
10336    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
10337    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
10338    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
10339    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
10340    .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;}}
10341    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
10342    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
10343    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
10344    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
10345    .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;}}
10346    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
10347    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
10348    .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);}}
10349    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
10350    .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;}}
10351    .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;}}
10352    .data-table tr:last-child td{{border-bottom:none;}}
10353    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
10354    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
10355    .table-wrap{{width:100%;overflow-x:auto;}}
10356    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
10357    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
10358    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10359    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
10360    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
10361    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
10362    .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;}}
10363    .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;}}
10364    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10365    .pagination-info{{font-size:13px;color:var(--muted);}}
10366    .pagination-btns{{display:flex;gap:6px;}}
10367    .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;}}
10368    .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;}}
10369    #scan-history-table col:nth-child(1){{width:155px;}}
10370    #scan-history-table col:nth-child(2){{width:240px;}}
10371    #scan-history-table col:nth-child(3){{width:82px;}}
10372    #scan-history-table col:nth-child(4){{width:82px;}}
10373    #scan-history-table col:nth-child(5){{width:90px;}}
10374    #scan-history-table col:nth-child(6){{width:90px;}}
10375    #scan-history-table col:nth-child(7){{width:88px;}}
10376    #scan-history-table col:nth-child(8){{width:150px;}}
10377    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
10378    .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;}}
10379    .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;}}
10380    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
10381    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
10382    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
10383    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
10384    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
10385    .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;}}
10386    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
10387    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
10388    .watched-chip-rm:hover{{color:var(--oxide);}}
10389    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
10390    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
10391    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
10392    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
10393    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
10394    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
10395    a.run-link:hover{{text-decoration:underline;}}
10396    .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);}}
10397    .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);}}
10398    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
10399    .metric-num{{font-weight:700;color:var(--text);}}
10400    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
10401    .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;}}
10402    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10403    .btn.primary:hover{{opacity:.9;}}
10404    .rpt-btn{{min-width:58px;justify-content:center;}}
10405    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
10406    .report-cell{{overflow:visible!important;white-space:normal!important;}}
10407    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
10408    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
10409    .submod-details summary::-webkit-details-marker{{display:none;}}
10410    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
10411    .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;}}
10412    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
10413    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
10414    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
10415    .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;}}
10416    .export-btn:hover{{background:var(--line);}}
10417    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
10418    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10419    .site-footer a{{color:var(--muted);}}
10420    .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;}}
10421    .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;}}
10422    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
10423  </style>
10424</head>
10425<body>
10426  <div class="background-watermarks" aria-hidden="true">
10427    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10428    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10429    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10430    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10431    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10432    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10433  </div>
10434  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10435  <div class="top-nav">
10436    <div class="top-nav-inner">
10437      <a class="brand" href="/">
10438        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10439        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
10440      </a>
10441      <div class="nav-right">
10442        <a class="nav-pill" href="/">Home</a>
10443        <div class="nav-dropdown">
10444          <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>
10445          <div class="nav-dropdown-menu">
10446            <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>
10447          </div>
10448        </div>
10449        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10450        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10451        <div class="nav-dropdown">
10452          <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>
10453          <div class="nav-dropdown-menu">
10454            <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>
10455          </div>
10456        </div>
10457        <div class="server-status-wrap" id="server-status-wrap">
10458          <div class="nav-pill server-online-pill" id="server-status-pill">
10459            <span class="status-dot" id="status-dot"></span>
10460            <span id="server-status-label">Server</span>
10461            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10462          </div>
10463          <div class="server-status-tip">
10464            OxideSLOC is running — accessible on your network.
10465            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10466          </div>
10467        </div>
10468        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10469          <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>
10470        </button>
10471        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10472          <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>
10473          <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>
10474        </button>
10475      </div>
10476    </div>
10477  </div>
10478
10479  <div class="page">
10480    {watched_dirs_html}
10481    <div class="summary-strip" id="trend-stats"></div>
10482    <div class="panel">
10483      <div class="trend-header">
10484        <div class="trend-title-block">
10485          <h1>Trend Reports</h1>
10486          <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root, choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
10487          <span class="chart-hint-inline">
10488            <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>
10489            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
10490          </span>
10491        </div>
10492        <div class="chart-actions">
10493          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
10494            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
10495            Retention Policy
10496          </button>
10497          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
10498            <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>
10499            Clean up old runs
10500          </button>
10501          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
10502            <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>
10503            Export Excel
10504          </button>
10505          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
10506            <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>
10507            Export PNG
10508          </button>
10509        </div>
10510      </div>
10511
10512      <div class="controls-centered">
10513        <label>Project Root:
10514          <select class="chart-select" id="root-sel">
10515            <option value="">All projects</option>
10516          </select>
10517        </label>
10518        <label>Y Metric:
10519          <select class="chart-select" id="y-sel">
10520            <option value="code_lines">Code Lines</option>
10521            <option value="comment_lines">Comment Lines</option>
10522            <option value="blank_lines">Blank Lines</option>
10523            <option value="physical_lines">Physical Lines</option>
10524            <option value="files_analyzed">Files Analyzed</option>
10525          </select>
10526        </label>
10527        <label>X Axis:
10528          <select class="chart-select" id="x-sel">
10529            <option value="time">By Time</option>
10530            <option value="commit">By Commit</option>
10531            <option value="release">By Release</option>
10532            <option value="tag">Tagged Commits</option>
10533          </select>
10534        </label>
10535        <label id="submodule-label" style="display:none;">Submodule:
10536          <select class="chart-select" id="sub-sel">
10537            <option value="">All (project total)</option>
10538          </select>
10539        </label>
10540        <label>Chart Size:
10541          <select class="chart-select" id="scale-sel">
10542            <option value="0.75">Compact</option>
10543            <option value="1.2" selected>Normal</option>
10544            <option value="1.38">Large</option>
10545          </select>
10546        </label>
10547      </div>
10548
10549      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
10550      <div id="data-table-wrap" style="overflow-x:auto;"></div>
10551    </div>
10552  </div>
10553
10554  <script nonce="{nonce}">
10555    (function() {{
10556      // Theme persistence
10557      var b = document.body;
10558      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10559      var tgl = document.getElementById('theme-toggle');
10560      if (tgl) tgl.addEventListener('click', function() {{
10561        var d = b.classList.toggle('dark-theme');
10562        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10563      }});
10564
10565      // Watermark randomizer
10566      (function() {{
10567        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10568        if (!wms.length) return;
10569        var placed = [];
10570        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;}}
10571        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];}}
10572        var half=Math.floor(wms.length/2);
10573        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;}});
10574      }})();
10575
10576      // Code particles
10577      (function() {{
10578        var container = document.getElementById('code-particles');
10579        if (!container) return;
10580        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'];
10581        for (var i = 0; i < 38; i++) {{
10582          (function(idx) {{
10583            var el = document.createElement('span');
10584            el.className = 'code-particle';
10585            el.textContent = snippets[idx % snippets.length];
10586            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10587            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10588            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10589            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';
10590            container.appendChild(el);
10591          }})(i);
10592        }}
10593      }})();
10594
10595      // Watched folder picker
10596      (function() {{
10597        var btn = document.getElementById('add-watched-btn');
10598        if (!btn) return;
10599        btn.addEventListener('click', function() {{
10600          fetch('/pick-directory?kind=reports')
10601            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10602            .then(function(data) {{
10603              if (!data.cancelled && data.selected_path) {{
10604                var form = document.createElement('form');
10605                form.method = 'POST';
10606                form.action = '/watched-dirs/add';
10607                var ri = document.createElement('input');
10608                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10609                var fi = document.createElement('input');
10610                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10611                form.appendChild(ri); form.appendChild(fi);
10612                document.body.appendChild(form);
10613                form.submit();
10614              }}
10615            }})
10616            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10617        }});
10618      }})();
10619
10620      // Settings / color-scheme modal
10621      (function() {{
10622        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'}}];
10623        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);}});}}
10624        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10625        var btn=document.getElementById('settings-btn');if(!btn)return;
10626        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10627        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>';
10628        document.body.appendChild(m);
10629        var g=document.getElementById('scheme-grid');
10630        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);}});
10631        var cl=document.getElementById('settings-close');
10632        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);
10633        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');}});
10634        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10635        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10636      }})();
10637    }})();
10638
10639    var ROOTS = {roots_json};
10640    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
10641    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
10642    var allData = [];
10643
10644    // Populate root selector
10645    var rootSel = document.getElementById('root-sel');
10646    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
10647
10648    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();}}
10649    function fmtFull(n){{return Number(n).toLocaleString();}}
10650    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
10651
10652    // Tooltip
10653    var tt = document.createElement('div');
10654    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:9999;max-width:280px;color:var(--text);';
10655    document.body.appendChild(tt);
10656    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
10657    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';}}
10658    function hideTT(){{tt.style.display='none';}}
10659    window.addEventListener('blur',function(){{hideTT();}});
10660    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
10661
10662    function statExact(compact, full){{
10663      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
10664    }}
10665    function statVal(n){{
10666      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
10667    }}
10668
10669    function updateStats(data){{
10670      var statsEl=document.getElementById('trend-stats');
10671      if(!statsEl)return;
10672      if(!data||!data.length){{statsEl.innerHTML='';return;}}
10673      var yKey=document.getElementById('y-sel').value;
10674      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10675      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10676      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
10677      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
10678      var absDelta=Math.abs(delta);
10679      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
10680      var deltaExact=statExact(deltaCompact,deltaFull);
10681      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
10682      statsEl.innerHTML=
10683        '<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>'+
10684        '<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>'+
10685        '<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>'+
10686        '<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>';
10687    }}
10688
10689    var subSel = document.getElementById('sub-sel');
10690    var subLabel = document.getElementById('submodule-label');
10691
10692    function populateSubmodules(root){{
10693      if(!subSel||!subLabel)return;
10694      while(subSel.options.length>1)subSel.remove(1);
10695      subSel.value='';
10696      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
10697      fetch(url)
10698        .then(function(r){{return r.json();}})
10699        .then(function(subs){{
10700          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
10701          subs.forEach(function(s){{
10702            var o=document.createElement('option');
10703            o.value=s.name;
10704            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
10705            subSel.appendChild(o);
10706          }});
10707          subLabel.style.display='';
10708        }})
10709        .catch(function(){{subLabel.style.display='none';}});
10710    }}
10711
10712    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
10713
10714    function loadAndRender(){{
10715      var root = rootSel.value;
10716      var sub = subSel ? subSel.value : '';
10717      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
10718      document.getElementById('data-table-wrap').innerHTML='';
10719      var url = '/api/metrics/history?limit=100'
10720        + (root ? '&root='+encodeURIComponent(root) : '')
10721        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
10722      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
10723        allData = data;
10724        render(data);
10725        updateStats(data);
10726      }}).catch(function(){{
10727        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>';
10728      }});
10729    }}
10730
10731    function render(data){{
10732      var yKey = document.getElementById('y-sel').value;
10733      var xMode = document.getElementById('x-sel').value;
10734
10735      // Filter for tag/release mode
10736      var pts = data;
10737      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
10738
10739      // Sort oldest-first for the line chart
10740      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10741
10742      var wrap = document.getElementById('chart-wrap');
10743      if(!pts.length){{
10744        var emptyMsg = (xMode === 'tag')
10745          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
10746          : 'No scan data found for the selected filters.';
10747        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
10748        renderTable([]);
10749        return;
10750      }}
10751
10752      var scaleEl=document.getElementById('scale-sel');
10753      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
10754      var W=Math.round(900*sc),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;
10755      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
10756
10757      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10758
10759      var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
10760      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>';
10761
10762      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
10763
10764      // Grid + Y axis ticks
10765      for(var ti=0;ti<=5;ti++){{
10766        var gy=PT+CH-Math.round(ti/5*CH);
10767        var gv=Math.round(ti/5*maxY);
10768        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
10769        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
10770      }}
10771
10772      // X axis labels (every N-th point to avoid crowding)
10773      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
10774      pts.forEach(function(d,i){{
10775        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10776        if(i%labelEvery===0||i===pts.length-1){{
10777          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)));
10778          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>';
10779        }}
10780      }});
10781
10782      // Axis label
10783      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
10784      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>';
10785      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>';
10786
10787      // Area fill + line path
10788      var pathD='';
10789      pts.forEach(function(d,i){{
10790        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10791        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10792        pathD+=(i===0?'M':'L')+x+','+y;
10793      }});
10794      if(pts.length>1){{
10795        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
10796        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
10797      }}
10798      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
10799
10800      // Data points (clickable) + permanent value labels
10801      var showLabels = pts.length <= 40;
10802      var labelEveryN = pts.length > 20 ? 2 : 1;
10803      pts.forEach(function(d,i){{
10804        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10805        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10806        var hasTags=d.tags&&d.tags.length>0;
10807        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
10808        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
10809        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+'"/>';
10810        if(showLabels && i%labelEveryN===0){{
10811          var lx=x, ly=y-r-5;
10812          svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmt(Number(d[yKey]))+'</text>';
10813        }}
10814      }});
10815
10816      svg+='</svg>';
10817      wrap.innerHTML=svg;
10818
10819      // Attach point tooltips
10820      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
10821        c.addEventListener('mouseover',function(e){{
10822          var d=pts[parseInt(this.dataset.idx)];
10823          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(''):'';
10824          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>':'';
10825          showTT(e,
10826            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
10827            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
10828            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
10829            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
10830          );
10831          this.setAttribute('r','8');
10832        }});
10833        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
10834        c.addEventListener('mousemove',moveTT);
10835        c.addEventListener('click',function(){{
10836          var d=pts[parseInt(this.dataset.idx)];
10837          if(d.html_url) window.open(d.html_url,'_blank');
10838        }});
10839      }});
10840
10841      renderTable(pts, yKey);
10842    }}
10843
10844    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
10845    var shProjFilter='', shBranchFilter='';
10846
10847    function fmtPST(isoStr){{
10848      if(!isoStr)return'';
10849      var d=new Date(isoStr);
10850      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
10851      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);}}
10852      function p(n){{return n<10?'0'+n:String(n);}}
10853      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++;}}}}
10854      var yr=d.getUTCFullYear();
10855      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
10856      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
10857      var isDST=d>=dstStart&&d<dstEnd;
10858      var off=isDST?-7*3600*1000:-8*3600*1000;
10859      var lbl=isDST?'PDT':'PST';
10860      var loc=new Date(d.getTime()+off);
10861      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
10862    }}
10863
10864    function getShRows(){{
10865      var proj=shProjFilter.toLowerCase().trim();
10866      var branch=shBranchFilter;
10867      return shData.filter(function(d){{
10868        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
10869        if(branch&&(d.branch||'')!==branch)return false;
10870        return true;
10871      }});
10872    }}
10873
10874    function renderShPage(){{
10875      var filtered=getShRows();
10876      if(shSortCol){{
10877        filtered.sort(function(a,b){{
10878          var va,vb;
10879          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
10880          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
10881          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
10882          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
10883          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
10884          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
10885        }});
10886      }}
10887      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
10888      shPage=Math.min(shPage,totalPages);
10889      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
10890      var visible=filtered.slice(start,end);
10891      var tbody=document.getElementById('sh-tbody');
10892      if(!tbody)return;
10893      tbody.innerHTML=visible.map(function(d){{
10894        var tsHtml=esc(fmtPST(d.timestamp));
10895        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>';
10896        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>';
10897        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
10898        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
10899        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
10900        var reportCell='';
10901        if(d.html_url){{
10902          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
10903          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>';}}
10904          reportCell+='</div>';
10905        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
10906        if(d.submodule_links&&d.submodule_links.length){{
10907          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
10908          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
10909          reportCell+='</div></details>';
10910        }}
10911        return '<tr>'
10912          +'<td>'+tsHtml+'</td>'
10913          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
10914          +'<td>'+runIdHtml+'</td>'
10915          +'<td>'+commitHtml+'</td>'
10916          +'<td>'+branchHtml+'</td>'
10917          +'<td>'+tags+'</td>'
10918          +'<td class="num">'+metricHtml+'</td>'
10919          +'<td class="report-cell">'+reportCell+'</td>'
10920          +'</tr>';
10921      }}).join('');
10922      var pgRange=document.getElementById('sh-pg-range');
10923      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
10924      var pgInfo=document.getElementById('sh-pg-info');
10925      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
10926      var pgBtns=document.getElementById('sh-pg-btns');
10927      if(pgBtns){{
10928        pgBtns.innerHTML='';
10929        function mkPgBtn(lbl,pg,active,disabled){{
10930          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
10931          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
10932          return b;
10933        }}
10934        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
10935        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
10936        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
10937        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
10938      }}
10939    }}
10940
10941    function wireTableBehavior(){{
10942      var pf=document.getElementById('sh-proj-filter');
10943      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
10944      var bf=document.getElementById('sh-branch-filter');
10945      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
10946      var rb=document.getElementById('sh-reset-btn');
10947      if(rb)rb.addEventListener('click',function(){{
10948        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
10949        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
10950        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
10951        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');}});
10952        renderShPage();
10953      }});
10954      var pps=document.getElementById('sh-per-page');
10955      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
10956      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
10957      ths.forEach(function(th){{
10958        th.addEventListener('click',function(e){{
10959          if(e.target.classList.contains('col-resize-handle'))return;
10960          var col=th.dataset.col;
10961          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
10962          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
10963          th.classList.add('sort-'+shSortOrder);
10964          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
10965          shPage=1;renderShPage();
10966        }});
10967      }});
10968      var table=document.getElementById('scan-history-table');
10969      if(!table)return;
10970      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
10971      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
10972      allThs.forEach(function(th,i){{
10973        var handle=th.querySelector('.col-resize-handle');
10974        if(!handle||!cols[i])return;
10975        var startX,startW;
10976        handle.addEventListener('mousedown',function(e){{
10977          e.stopPropagation();e.preventDefault();
10978          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
10979          handle.classList.add('dragging');
10980          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
10981          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
10982          document.addEventListener('mousemove',onMove);
10983          document.addEventListener('mouseup',onUp);
10984        }});
10985      }});
10986    }}
10987
10988    function renderTable(pts, yKey){{
10989      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
10990      var wrap=document.getElementById('data-table-wrap');
10991      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
10992      var yLabel=Y_LABELS[yKey]||yKey||'';
10993      shData=pts.slice().reverse();
10994      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
10995      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
10996      var branches={{}};
10997      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
10998      var branchOpts='<option value="">All branches</option>';
10999      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
11000      wrap.innerHTML=
11001        '<div class="chart-section-header">SCAN HISTORY</div>'+
11002        '<div class="filter-row">'+
11003          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
11004          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
11005          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
11006        '</div>'+
11007        '<div class="table-wrap">'+
11008        '<table id="scan-history-table" class="data-table">'+
11009        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
11010        '<thead><tr id="sh-thead">'+
11011        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
11012        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
11013        '<th>Run ID<div class="col-resize-handle"></div></th>'+
11014        '<th>Commit<div class="col-resize-handle"></div></th>'+
11015        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
11016        '<th>Tags<div class="col-resize-handle"></div></th>'+
11017        '<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>'+
11018        '<th>Report<div class="col-resize-handle"></div></th>'+
11019        '</tr></thead>'+
11020        '<tbody id="sh-tbody"></tbody>'+
11021        '</table>'+
11022        '</div>'+
11023        '<div class="pagination">'+
11024          '<span class="pagination-info" id="sh-pg-info"></span>'+
11025          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
11026          '<div style="display:flex;align-items:center;gap:8px;">'+
11027            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
11028            '<select class="filter-select" id="sh-per-page">'+
11029              '<option value="10">10 per page</option>'+
11030              '<option value="25" selected>25 per page</option>'+
11031              '<option value="50">50 per page</option>'+
11032              '<option value="100">100 per page</option>'+
11033            '</select>'+
11034            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
11035          '</div>'+
11036        '</div>';
11037      wireTableBehavior();
11038      renderShPage();
11039    }}
11040
11041    function exportXLSX(){{
11042      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
11043      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
11044      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
11045      var s1R=sorted.map(function(d){{
11046        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',d.commit||'',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||''];
11047      }});
11048      var pm={{}};
11049      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
11050      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'];
11051      var s2R=Object.keys(pm).map(function(p){{
11052        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11053        var lat=sc[sc.length-1],fst=sc[0];
11054        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
11055        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);
11056        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];
11057      }});
11058      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
11059      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
11060      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
11061      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
11062    }}
11063
11064    function buildXLSX(sheets,chartRows,chartRows2){{
11065      function s2b(s){{return new TextEncoder().encode(s);}}
11066      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
11067      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;}}
11068      function crc32(d){{
11069        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;}}}}
11070        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
11071      }}
11072      function buildSheet(hdr,rows,drawRid,withCtrl){{
11073        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
11074        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
11075        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
11076        x+='<row r="1">';
11077        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
11078        if(withCtrl){{x+='<c r="M1" t="inlineStr" s="1"><is><t>&#8595; Metric Selector</t></is></c><c r="N1" t="inlineStr"><is><t>Code Lines</t></is></c>';}}
11079        x+='</row>';
11080        rows.forEach(function(row,ri){{
11081          var rn=ri+2;
11082          x+='<row r="'+rn+'">';
11083          row.forEach(function(cell,ci){{
11084            var addr=col2l(ci+1)+rn;
11085            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
11086            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
11087          }});
11088          if(withCtrl){{x+='<c r="M'+rn+'"><f>CHOOSE(MATCH($N$1,{{"Code Lines","Comment Lines","Blank Lines","Physical Lines"}},0),F'+rn+',G'+rn+',H'+rn+',I'+rn+')</f><v>'+Number(row[5])+'</v></c>';}}
11089          x+='</row>';
11090        }});
11091        x+='</sheetData>';
11092        if(withCtrl){{x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="N1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines"</formula1></dataValidation></dataValidations>';}}
11093        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
11094        return x+'</worksheet>';
11095      }}
11096      function buildChartXML(rows){{
11097        var sn="'Scan History'";
11098        var nr=rows.length,er=nr+1;
11099        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'}}];
11100        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11101        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">';
11102        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11103        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11104        sd.forEach(function(s,i){{
11105          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11106          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>';
11107          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11108          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>';
11109          var dlp=(i===2)?'b':'t';
11110          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="'+dlp+'"/></c:dLbls>';
11111          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11112          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11113          x+='</c:strCache></c:strRef></c:cat>';
11114          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+'"/>';
11115          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11116          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11117        }});
11118        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
11119        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>';
11120        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>';
11121        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11122        return x;
11123      }}
11124      function buildChartXML2(rows){{
11125        var sn="'By Project'";
11126        var nr=rows.length,er=nr+1;
11127        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'}}];
11128        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11129        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">';
11130        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11131        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11132        sd.forEach(function(s,i){{
11133          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11134          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>';
11135          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11136          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>';
11137          var dlp=(i===2)?'b':'t';
11138          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="'+dlp+'"/></c:dLbls>';
11139          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11140          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11141          x+='</c:strCache></c:strRef></c:cat>';
11142          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+'"/>';
11143          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11144          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11145        }});
11146        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
11147        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>';
11148        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>';
11149        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11150        return x;
11151      }}
11152      function buildChartXML3(rows){{
11153        var sn="'Scan History'";
11154        var nr=rows.length,er=nr+1;
11155        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11156        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">';
11157        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
11158        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11159        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
11160        x+='<c:tx><c:strRef><c:f>'+sn+'!$N$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>';
11161        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
11162        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>';
11163        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>';
11164        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11165        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11166        x+='</c:strCache></c:strRef></c:cat>';
11167        x+='<c:val><c:numRef><c:f>'+sn+'!$M$2:$M$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
11168        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
11169        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11170        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
11171        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>';
11172        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>';
11173        x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Focus View — change N1 to switch metric</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>';
11174        return x;
11175      }}
11176      var hasChart=!!(chartRows&&chartRows.length);
11177      var nr=hasChart?chartRows.length:0;
11178      var hasChart2=!!(chartRows2&&chartRows2.length);
11179      var nr2=hasChart2?chartRows2.length:0;
11180      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>';
11181      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"/>';
11182      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
11183      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"/>';}}
11184      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"/>';}}
11185      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
11186      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>';
11187      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
11188      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"/>';}});
11189      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
11190      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>';
11191      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
11192      wbx+='</sheets></workbook>';
11193      var files=[
11194        {{name:'[Content_Types].xml',data:s2b(ct)}},
11195        {{name:'_rels/.rels',data:s2b(dotrels)}},
11196        {{name:'xl/workbook.xml',data:s2b(wbx)}},
11197        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
11198        {{name:'xl/styles.xml',data:s2b(styl)}}
11199      ];
11200      // Chart embedded directly in Scan History (sheet1); By Project is plain
11201      sheets.forEach(function(s,i){{
11202        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0)))}});
11203      }});
11204      if(hasChart){{
11205        var fromRow=nr+4,toRow=nr+24;
11206        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>')}});
11207        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11208        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">';
11209        drx+='<xdr:twoCellAnchor editAs="twoCell">';
11210        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>';
11211        drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11212        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11213        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11214        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11215        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11216        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
11217        var focRow=toRow+2,focRowEnd=toRow+22;
11218        drx+='<xdr:twoCellAnchor editAs="twoCell">';
11219        drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
11220        drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRowEnd+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11221        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11222        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11223        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11224        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
11225        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
11226        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
11227        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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
11228        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
11229        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
11230      }}
11231      if(hasChart2){{
11232        var fromRow2=nr2+4,toRow2=nr2+24;
11233        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>')}});
11234        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11235        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">';
11236        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
11237        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>';
11238        drx2+='<xdr:to><xdr:col>11</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11239        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11240        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11241        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11242        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11243        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
11244        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
11245        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>')}});
11246        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
11247      }}
11248      var parts=[],offsets=[],total=0;
11249      files.forEach(function(f){{
11250        offsets.push(total);
11251        var nb=s2b(f.name),crc=crc32(f.data);
11252        var h=new DataView(new ArrayBuffer(30+nb.length));
11253        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
11254        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
11255        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
11256        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
11257        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
11258        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
11259        total+=30+nb.length+f.data.length;
11260      }});
11261      var cdStart=total;
11262      files.forEach(function(f,fi){{
11263        var nb=s2b(f.name),crc=crc32(f.data);
11264        var cd=new DataView(new ArrayBuffer(46+nb.length));
11265        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
11266        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
11267        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
11268        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
11269        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
11270        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
11271        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
11272      }});
11273      var cdSz=total-cdStart;
11274      var eocd=new DataView(new ArrayBuffer(22));
11275      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
11276      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
11277      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
11278      parts.push(new Uint8Array(eocd.buffer));
11279      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
11280      var out=new Uint8Array(sz);var off=0;
11281      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
11282      return out.buffer;
11283    }}
11284
11285    function exportPNG(){{
11286      var svgEl=document.querySelector('#chart-wrap svg');
11287      if(!svgEl){{alert('No chart to export yet.');return;}}
11288      var svgStr=new XMLSerializer().serializeToString(svgEl);
11289      var vb=svgEl.viewBox.baseVal,scale=2;
11290      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
11291      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
11292      var url=URL.createObjectURL(blob);
11293      var img=new Image();
11294      img.onload=function(){{
11295        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
11296        var ctx=canvas.getContext('2d');
11297        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
11298        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
11299        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
11300        URL.revokeObjectURL(url);
11301        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
11302      }};
11303      img.src=url;
11304    }}
11305
11306    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
11307      var el=document.getElementById(id);
11308      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
11309    }});
11310    rootSel.addEventListener('change',function(){{
11311      populateSubmodules(rootSel.value);
11312      loadAndRender();
11313    }});
11314    if(subSel)subSel.addEventListener('change',loadAndRender);
11315
11316    var xlsxBtn=document.getElementById('export-xlsx-btn');
11317    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
11318    var pngBtn=document.getElementById('export-png-btn');
11319    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
11320
11321    // ── Clean-up modal ───────────────────────────────────────────────────────
11322    (function(){{
11323      var triggerBtn=document.getElementById('cleanup-runs-btn');
11324      if(!triggerBtn)return;
11325      var modal=document.createElement('div');
11326      modal.style.cssText='display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;';
11327      modal.innerHTML='<div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">'
11328        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
11329        +'<p style="font-size:13px;color:var(--text);margin:0 0 14px;">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>'
11330        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
11331        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
11332        +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:80px;padding:7px 10px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
11333        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
11334        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
11335        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
11336        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
11337        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
11338        +'</div></div>';
11339      document.body.appendChild(modal);
11340      triggerBtn.addEventListener('click',function(){{
11341        document.getElementById('cleanup-status').style.display='none';
11342        modal.style.display='flex';
11343      }});
11344      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
11345      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11346      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
11347        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
11348        var confirmBtn=this;
11349        confirmBtn.disabled=true;
11350        var status=document.getElementById('cleanup-status');
11351        status.style.display='block';
11352        status.style.background='#dbeafe';status.style.color='#1e40af';
11353        status.textContent='Deleting\u2026';
11354        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
11355        .then(function(resp){{
11356          return resp.json().then(function(d){{
11357            if(resp.ok){{
11358              status.style.background='#dcfce7';status.style.color='#166534';
11359              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
11360              setTimeout(function(){{window.location.reload();}},1500);
11361            }}else{{
11362              status.style.background='#fee2e2';status.style.color='#991b1b';
11363              status.textContent='Error: '+(d.error||'Unexpected error');
11364              confirmBtn.disabled=false;
11365            }}
11366          }});
11367        }})
11368        .catch(function(e){{
11369          status.style.background='#fee2e2';status.style.color='#991b1b';
11370          status.textContent='Network error: '+String(e);
11371          confirmBtn.disabled=false;
11372        }});
11373      }});
11374    }})();
11375
11376    // ── Retention policy panel ────────────────────────────────────────────────
11377    (function(){{
11378      var triggerBtn=document.getElementById('retention-policy-btn');
11379      if(!triggerBtn)return;
11380      var modal=document.createElement('div');
11381      modal.style.cssText='display:none;position:fixed;inset:0;z-index:9001;background:rgba(0,0,0,0.72);align-items:center;justify-content:center;';
11382      modal.innerHTML=''
11383        +'<div style="background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:36px 44px;max-width:580px;width:95%;box-shadow:0 24px 64px rgba(0,0,0,0.38);">'
11384        +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
11385        +'<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 — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
11386        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
11387        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
11388        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
11389        +'</div>'
11390        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
11391        +'<div>'
11392        +'<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>'
11393        +'<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;">'
11394        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
11395        +'</div>'
11396        +'<div>'
11397        +'<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>'
11398        +'<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;">'
11399        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
11400        +'</div>'
11401        +'</div>'
11402        +'<div style="margin-bottom:20px;">'
11403        +'<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>'
11404        +'<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;">'
11405        +'<option value="1">Every hour</option>'
11406        +'<option value="6">Every 6 hours</option>'
11407        +'<option value="12">Every 12 hours</option>'
11408        +'<option value="24" selected>Every 24 hours</option>'
11409        +'<option value="48">Every 2 days</option>'
11410        +'<option value="72">Every 3 days</option>'
11411        +'<option value="168">Every week</option>'
11412        +'</select>'
11413        +'</div>'
11414        +'<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;">—</div>'
11415        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
11416        +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
11417        +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
11418        +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
11419        +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
11420        +'</div>'
11421        +'</div>';
11422      document.body.appendChild(modal);
11423
11424      function rpShowStatus(msg,ok){{
11425        var s=document.getElementById('rp-status');
11426        s.style.display='block';
11427        s.style.background=ok?'#dcfce7':'#fee2e2';
11428        s.style.color=ok?'#166534':'#991b1b';
11429        s.textContent=msg;
11430      }}
11431      function fmtAgo(iso){{
11432        if(!iso)return'Never';
11433        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
11434        if(diff<60)return diff+'s ago';
11435        if(diff<3600)return Math.floor(diff/60)+'m ago';
11436        if(diff<86400)return Math.floor(diff/3600)+'h ago';
11437        return Math.floor(diff/86400)+'d ago';
11438      }}
11439      function loadPolicy(){{
11440        fetch('/api/cleanup-policy')
11441          .then(function(r){{return r.json();}})
11442          .then(function(d){{
11443            var p=d.policy;
11444            document.getElementById('rp-enabled').checked=p?p.enabled:false;
11445            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
11446            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
11447            var sel=document.getElementById('rp-interval');
11448            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;}}}}}}
11449            var lr=document.getElementById('rp-last-run');
11450            if(d.last_run_at){{
11451              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'):'');
11452            }}else{{
11453              lr.textContent='Auto-cleanup has not run yet.';
11454            }}
11455          }})
11456          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
11457      }}
11458
11459      triggerBtn.addEventListener('click',function(){{
11460        document.getElementById('rp-status').style.display='none';
11461        loadPolicy();
11462        modal.style.display='flex';
11463      }});
11464      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
11465      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11466
11467      document.getElementById('rp-save-btn').addEventListener('click',function(){{
11468        var enabled=document.getElementById('rp-enabled').checked;
11469        var ageVal=document.getElementById('rp-max-age').value.trim();
11470        var countVal=document.getElementById('rp-max-count').value.trim();
11471        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
11472        if(enabled&&!ageVal&&!countVal){{
11473          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
11474          return;
11475        }}
11476        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
11477        var saveBtn=document.getElementById('rp-save-btn');
11478        saveBtn.disabled=true;
11479        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
11480          .then(function(r){{
11481            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
11482            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
11483          }})
11484          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11485          .finally(function(){{saveBtn.disabled=false;}});
11486      }});
11487
11488      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
11489        var btn=this;
11490        btn.disabled=true;
11491        btn.textContent='Running\u2026';
11492        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
11493          .then(function(r){{return r.json();}})
11494          .then(function(d){{
11495            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
11496            loadPolicy();
11497          }})
11498          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11499          .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
11500      }});
11501    }})();
11502
11503    populateSubmodules(rootSel.value);
11504    loadAndRender();
11505
11506    (function randomizeWatermarks() {{
11507      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11508      if (!wms.length) return;
11509      var placed = [];
11510      function tooClose(top, left) {{
11511        for (var i = 0; i < placed.length; i++) {{
11512          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
11513          if (dt < 16 && dl < 12) return true;
11514        }}
11515        return false;
11516      }}
11517      function pick(leftBand) {{
11518        for (var attempt = 0; attempt < 50; attempt++) {{
11519          var top = Math.random() * 88 + 2;
11520          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11521          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
11522        }}
11523        var top = Math.random() * 88 + 2;
11524        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11525        placed.push([top, left]); return [top, left];
11526      }}
11527      var half = Math.floor(wms.length / 2);
11528      wms.forEach(function (img, i) {{
11529        var pos = pick(i < half);
11530        var size = Math.floor(Math.random() * 100 + 120);
11531        var rot = (Math.random() * 360).toFixed(1);
11532        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
11533        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;
11534      }});
11535    }})();
11536    (function spawnCodeParticles() {{
11537      var container = document.getElementById('code-particles');
11538      if (!container) return;
11539      var snippets = [
11540        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
11541        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
11542        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
11543        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
11544        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
11545      ];
11546      var count = 38;
11547      for (var i = 0; i < count; i++) {{
11548        (function(idx) {{
11549          var el = document.createElement('span');
11550          el.className = 'code-particle';
11551          el.textContent = snippets[idx % snippets.length];
11552          var left = Math.random() * 94 + 2;
11553          var top = Math.random() * 88 + 6;
11554          var dur = (Math.random() * 10 + 9).toFixed(1);
11555          var delay = (Math.random() * 18).toFixed(1);
11556          var rot = (Math.random() * 26 - 13).toFixed(1);
11557          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11558          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
11559          container.appendChild(el);
11560        }})(i);
11561      }}
11562    }})();
11563  </script>
11564  <footer class="site-footer">
11565    local code analysis - metrics, history and reports
11566    &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>
11567    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11568    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11569    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11570    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
11571  </footer>
11572  <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} — 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>
11573</body>
11574</html>"##,
11575    );
11576
11577    Html(html).into_response()
11578}
11579
11580fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11581    use std::collections::HashMap;
11582    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
11583        return vec![];
11584    }
11585    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11586    for rec in per_file_records {
11587        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11588            let e = totals.entry(lang.display_name().to_string()).or_default();
11589            e.0 += u64::from(cov.lines_found);
11590            e.1 += u64::from(cov.lines_hit);
11591        }
11592    }
11593    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
11594    let mut pairs: Vec<(String, f64)> = totals
11595        .into_iter()
11596        .filter(|(_, (found, _))| *found > 0)
11597        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11598        .collect();
11599    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11600    pairs
11601        .iter()
11602        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
11603        .collect()
11604}
11605
11606fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
11607    let mut high = 0u64;
11608    let mut mid = 0u64;
11609    let mut low = 0u64;
11610    for rec in per_file_records {
11611        if let Some(cov) = &rec.coverage {
11612            if cov.lines_found == 0 {
11613                continue;
11614            }
11615            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
11616            if pct >= 80.0 {
11617                high += 1;
11618            } else if pct >= 50.0 {
11619                mid += 1;
11620            } else {
11621                low += 1;
11622            }
11623        }
11624    }
11625    (high, mid, low)
11626}
11627
11628fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11629    let mut arr: Vec<serde_json::Value> = per_file_records
11630        .iter()
11631        .filter_map(|rec| {
11632            rec.coverage.as_ref().map(|cov| {
11633                let line_pct = if cov.lines_found > 0 {
11634                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
11635                        / 10.0
11636                } else {
11637                    0.0
11638                };
11639                let fn_pct = if cov.functions_found > 0 {
11640                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
11641                        .round()
11642                        / 10.0
11643                } else {
11644                    -1.0
11645                };
11646                serde_json::json!({
11647                    "rel": rec.relative_path,
11648                    "lang": rec.language.map_or("?", |l| l.display_name()),
11649                    "line_pct": line_pct,
11650                    "fn_pct": fn_pct,
11651                    "lhit": cov.lines_hit,
11652                    "lfound": cov.lines_found,
11653                    "fhit": cov.functions_hit,
11654                    "ffound": cov.functions_found,
11655                })
11656            })
11657        })
11658        .collect();
11659    arr.sort_by(|a, b| {
11660        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
11661        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
11662        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
11663    });
11664    arr
11665}
11666
11667#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11668fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
11669    let mut langs: Vec<&sloc_core::LanguageSummary> = run
11670        .totals_by_language
11671        .iter()
11672        .filter(|l| l.test_count > 0)
11673        .collect();
11674    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11675    let lang_tests: Vec<serde_json::Value> = langs
11676        .iter()
11677        .map(|l| {
11678            let d = if l.code_lines > 0 {
11679                l.test_count as f64 / l.code_lines as f64 * 1000.0
11680            } else {
11681                0.0
11682            };
11683            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11684                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11685                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11686        })
11687        .collect();
11688    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
11689    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11690    let t = &run.summary_totals;
11691    let total_tests = t.test_count;
11692    let density = if t.code_lines > 0 {
11693        total_tests as f64 / t.code_lines as f64 * 1000.0
11694    } else {
11695        0.0
11696    };
11697    let most_tested = langs.first().map_or_else(
11698        || "\u{2014}".to_string(),
11699        |l| l.language.display_name().to_string(),
11700    );
11701    let test_files: u64 = run
11702        .per_file_records
11703        .iter()
11704        .filter(|f| f.raw_line_categories.test_count > 0)
11705        .count() as u64;
11706    let cov_line = if t.coverage_lines_found > 0 {
11707        format!(
11708            "{:.1}",
11709            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
11710        )
11711    } else {
11712        "0".to_string()
11713    };
11714    let cov_fn = if t.coverage_functions_found > 0 {
11715        format!(
11716            "{:.1}",
11717            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
11718        )
11719    } else {
11720        "0".to_string()
11721    };
11722    let cov_branch = if t.coverage_branches_found > 0 {
11723        format!(
11724            "{:.1}",
11725            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
11726        )
11727    } else {
11728        "0".to_string()
11729    };
11730    let has_cov = !cov_arr.is_empty();
11731    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
11732    serde_json::json!({
11733        "totals": {
11734            "test_count": total_tests,
11735            "assertions": t.test_assertion_count,
11736            "suites": t.test_suite_count,
11737            "test_files": test_files,
11738            "total_files": t.files_analyzed,
11739            "density_str": format!("{density:.1}"),
11740            "most_tested": most_tested,
11741            "langs_with_tests": langs.len(),
11742            "cov_line": cov_line,
11743            "cov_fn": cov_fn,
11744            "cov_branch": cov_branch,
11745        },
11746        "lang_tests": lang_tests,
11747        "cov": cov_arr,
11748        "cov_tiers": {"high": high, "mid": mid, "low": low},
11749        "file_cov": file_cov_arr,
11750        "has_coverage": has_cov,
11751        "submodules": {},
11752    })
11753}
11754
11755#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11756fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
11757    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
11758        .language_summaries
11759        .iter()
11760        .filter(|l| l.test_count > 0)
11761        .collect();
11762    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11763    let lang_tests: Vec<serde_json::Value> = langs
11764        .iter()
11765        .map(|l| {
11766            let d = if l.code_lines > 0 {
11767                l.test_count as f64 / l.code_lines as f64 * 1000.0
11768            } else {
11769                0.0
11770            };
11771            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11772                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11773                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11774        })
11775        .collect();
11776    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
11777    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
11778    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
11779    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
11780    let density = if sub.code_lines > 0 {
11781        total_tests as f64 / sub.code_lines as f64 * 1000.0
11782    } else {
11783        0.0
11784    };
11785    let most_tested = langs.first().map_or_else(
11786        || "\u{2014}".to_string(),
11787        |l| l.language.display_name().to_string(),
11788    );
11789    serde_json::json!({
11790        "totals": {
11791            "test_count": total_tests,
11792            "assertions": total_assertions,
11793            "suites": total_suites,
11794            "test_files": test_files_approx,
11795            "total_files": sub.files_analyzed,
11796            "density_str": format!("{density:.1}"),
11797            "most_tested": most_tested,
11798            "langs_with_tests": langs.len(),
11799            "cov_line": "0",
11800            "cov_fn": "0",
11801            "cov_branch": "0",
11802        },
11803        "lang_tests": lang_tests,
11804        "cov": [],
11805        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
11806        "has_coverage": false,
11807    })
11808}
11809
11810fn compute_cov_json_str(run: &AnalysisRun) -> String {
11811    use std::collections::HashMap;
11812    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11813    for rec in &run.per_file_records {
11814        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11815            let e = totals.entry(lang.display_name().to_string()).or_default();
11816            e.0 += u64::from(cov.lines_found);
11817            e.1 += u64::from(cov.lines_hit);
11818        }
11819    }
11820    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
11821    let mut pairs: Vec<(String, f64)> = totals
11822        .into_iter()
11823        .filter(|(_, (found, _))| *found > 0)
11824        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11825        .collect();
11826    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11827    let parts: Vec<String> = pairs
11828        .iter()
11829        .map(|(lang, pct)| {
11830            let name = lang.replace('"', "\\\"");
11831            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
11832        })
11833        .collect();
11834    format!("[{}]", parts.join(","))
11835}
11836
11837fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
11838    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11839    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
11840}
11841
11842fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
11843    let mut entry = build_test_scope_entry(run);
11844    if !run.submodule_summaries.is_empty() {
11845        let subs: serde_json::Map<String, serde_json::Value> = run
11846            .submodule_summaries
11847            .iter()
11848            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
11849            .collect();
11850        entry["submodules"] = serde_json::Value::Object(subs);
11851    }
11852    entry
11853}
11854
11855fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
11856    let name = l.language.display_name().replace('"', "\\\"");
11857    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
11858    let density = if l.code_lines > 0 {
11859        l.test_count as f64 / l.code_lines as f64 * 1000.0
11860    } else {
11861        0.0
11862    };
11863    format!(
11864        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
11865        name = name,
11866        t = l.test_count,
11867        a = l.test_assertion_count,
11868        s = l.test_suite_count,
11869        c = l.code_lines,
11870        d = density,
11871        f = l.files,
11872    )
11873}
11874
11875fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
11876    let Some(r) = run else {
11877        return "[]".to_string();
11878    };
11879    let mut langs: Vec<&sloc_core::LanguageSummary> = r
11880        .totals_by_language
11881        .iter()
11882        .filter(|l| l.test_count > 0)
11883        .collect();
11884    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11885    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
11886    format!("[{}]", parts.join(","))
11887}
11888
11889/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
11890async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
11891    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
11892    scope_map.insert(
11893        "__all__".to_string(),
11894        latest_run.map_or_else(
11895            || {
11896                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
11897                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
11898                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
11899                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
11900                    "has_coverage":false,"submodules":{}})
11901            },
11902            build_test_scope_entry,
11903        ),
11904    );
11905    let all_roots: Vec<String> = {
11906        let reg = state.registry.lock().await;
11907        let mut seen = std::collections::BTreeSet::new();
11908        reg.entries
11909            .iter()
11910            .flat_map(|e| e.input_roots.iter().cloned())
11911            .filter(|r| seen.insert(r.clone()))
11912            .collect()
11913    };
11914    for root in &all_roots {
11915        let json_path = {
11916            let reg = state.registry.lock().await;
11917            reg.entries
11918                .iter()
11919                .find(|e| e.input_roots.iter().any(|r| r == root))
11920                .and_then(|e| e.json_path.clone())
11921        };
11922        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
11923            let json_str = tokio::fs::read_to_string(&p).await.ok();
11924            json_str
11925                .as_deref()
11926                .and_then(|s| serde_json::from_str(s).ok())
11927        } else {
11928            None
11929        };
11930        if let Some(ref run) = run_for_root {
11931            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
11932        }
11933    }
11934    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
11935}
11936
11937// GET /test-metrics
11938#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11939#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
11940async fn test_metrics_handler(
11941    State(state): State<AppState>,
11942    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11943) -> Response {
11944    auto_scan_watched_dirs(&state).await;
11945    let watched_dirs_list: Vec<String> = {
11946        let wd = state.watched_dirs.lock().await;
11947        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11948    };
11949    let latest_run: Option<AnalysisRun> = {
11950        let json_path = {
11951            let reg = state.registry.lock().await;
11952            reg.entries.first().and_then(|e| e.json_path.clone())
11953        };
11954        if let Some(p) = json_path {
11955            let json_str = tokio::fs::read_to_string(&p).await.ok();
11956            json_str
11957                .as_deref()
11958                .and_then(|s| serde_json::from_str(s).ok())
11959        } else {
11960            None
11961        }
11962    };
11963
11964    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
11965    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
11966
11967    // Build coverage chart JSON (per-language avg line coverage %).
11968    let cov_json: String = latest_run
11969        .as_ref()
11970        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11971        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
11972
11973    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
11974    let _cov_tier_json: String = latest_run
11975        .as_ref()
11976        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11977        .map_or_else(
11978            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
11979            compute_cov_tier_json_str,
11980        );
11981
11982    let total_tests: u64 = latest_run
11983        .as_ref()
11984        .map_or(0, |r| r.summary_totals.test_count);
11985    let total_assertions: u64 = latest_run
11986        .as_ref()
11987        .map_or(0, |r| r.summary_totals.test_assertion_count);
11988    let total_suites: u64 = latest_run
11989        .as_ref()
11990        .map_or(0, |r| r.summary_totals.test_suite_count);
11991    let total_code: u64 = latest_run
11992        .as_ref()
11993        .map_or(0, |r| r.summary_totals.code_lines);
11994    let workspace_density: f64 = if total_code > 0 {
11995        total_tests as f64 / total_code as f64 * 1000.0
11996    } else {
11997        0.0
11998    };
11999    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
12000        r.totals_by_language
12001            .iter()
12002            .filter(|l| l.test_count > 0)
12003            .count()
12004    });
12005    let most_tested: String = latest_run
12006        .as_ref()
12007        .and_then(|r| {
12008            r.totals_by_language
12009                .iter()
12010                .filter(|l| l.test_count > 0)
12011                .max_by_key(|l| l.test_count)
12012        })
12013        .map_or_else(
12014            || "\u{2014}".to_string(),
12015            |l| l.language.display_name().to_string(),
12016        );
12017    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
12018        r.per_file_records
12019            .iter()
12020            .filter(|f| f.raw_line_categories.test_count > 0)
12021            .count() as u64
12022    });
12023    let total_files_analyzed: u64 = latest_run
12024        .as_ref()
12025        .map_or(0, |r| r.summary_totals.files_analyzed);
12026    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
12027
12028    // Aggregated coverage percentages from summary_totals
12029    let cov_line_pct_str: String = latest_run
12030        .as_ref()
12031        .filter(|r| r.summary_totals.coverage_lines_found > 0)
12032        .map_or_else(
12033            || "0".to_string(),
12034            |r| {
12035                format!(
12036                    "{:.1}",
12037                    r.summary_totals.coverage_lines_hit as f64
12038                        / r.summary_totals.coverage_lines_found as f64
12039                        * 100.0
12040                )
12041            },
12042        );
12043    let cov_fn_pct_str: String = latest_run
12044        .as_ref()
12045        .filter(|r| r.summary_totals.coverage_functions_found > 0)
12046        .map_or_else(
12047            || "0".to_string(),
12048            |r| {
12049                format!(
12050                    "{:.1}",
12051                    r.summary_totals.coverage_functions_hit as f64
12052                        / r.summary_totals.coverage_functions_found as f64
12053                        * 100.0
12054                )
12055            },
12056        );
12057    let cov_branch_pct_str: String = latest_run
12058        .as_ref()
12059        .filter(|r| r.summary_totals.coverage_branches_found > 0)
12060        .map_or_else(
12061            || "0".to_string(),
12062            |r| {
12063                format!(
12064                    "{:.1}",
12065                    r.summary_totals.coverage_branches_hit as f64
12066                        / r.summary_totals.coverage_branches_found as f64
12067                        * 100.0
12068                )
12069            },
12070        );
12071
12072    let cov_no_data_notice = if has_coverage {
12073        String::new()
12074    } else {
12075        String::from(
12076            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
12077<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>
12078<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
12079  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
12080  <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>
12081  <span style="color:var(--muted);font-size:12px;">&middot;</span>
12082  <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>
12083  <span style="color:var(--muted);font-size:12px;">&middot;</span>
12084  <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>
12085</div>
12086<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
12087</div>"#,
12088        )
12089    };
12090
12091    let workspace_density_str = format!("{workspace_density:.1}");
12092    let nonce = &csp_nonce;
12093    let version = env!("CARGO_PKG_VERSION");
12094
12095    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
12096    // of interactive controls — folder watching is managed by the host administrator.
12097    let watched_dirs_html: String = if state.server_mode {
12098        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()
12099    } else {
12100        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12101            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12102                .to_string()
12103        } else {
12104            watched_dirs_list
12105                .iter()
12106                .fold(String::new(), |mut s, d| {
12107                    use std::fmt::Write as _;
12108                    let escaped =
12109                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
12110                    write!(
12111                        s,
12112                        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>"#
12113                    ).expect("write to String is infallible");
12114                    s
12115                })
12116        };
12117        format!(
12118            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>"#
12119        )
12120    };
12121
12122    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
12123    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
12124
12125    let html = format!(
12126        r#"<!doctype html>
12127<html lang="en">
12128<head>
12129  <meta charset="utf-8" />
12130  <meta name="viewport" content="width=device-width, initial-scale=1" />
12131  <title>OxideSLOC | Test Metrics</title>
12132  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12133  <style nonce="{nonce}">
12134    :root {{
12135      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12136      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12137      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12138      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12139      --info-bg:#eef3ff; --info-text:#4467d8;
12140    }}
12141    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12142    *{{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;}}
12143    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12144    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12145    .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;}}
12146    @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));}}}}
12147    .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);}}
12148    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12149    .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));}}
12150    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12151    .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;}}
12152    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12153    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12154    @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; }} }}
12155    .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;}}
12156    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12157    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12158    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12159    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12160    .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;}}
12161    .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;}}
12162    .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;}}
12163    .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;}}
12164    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12165    .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);}}
12166    .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;}}
12167    .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;}}
12168    .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;}}
12169    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12170    .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;}}
12171    .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);}}
12172    .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;}}
12173    .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;}}
12174    .tz-select:focus{{border-color:var(--oxide);}}
12175    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12176    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12177    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12178    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12179    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12180    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12181    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12182    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
12183    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12184    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12185    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12186    .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;}}
12187    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;}}
12188    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12189    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
12190    .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);}}
12191    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
12192    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
12193    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
12194    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
12195    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
12196    .chart-canvas-wrap{{position:relative;height:280px;}}
12197    .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;}}
12198    .chart-no-data svg{{opacity:0.35;}}
12199    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
12200    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
12201    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
12202    .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;}}
12203    .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;}}
12204    .data-table tr:last-child td{{border-bottom:none;}}
12205    .data-table tbody tr:hover td{{background:var(--surface-2);}}
12206    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
12207    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
12208    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
12209    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
12210    .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 .2s ease,box-shadow .2s ease;min-width:0;}}
12211    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
12212    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
12213    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
12214    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
12215    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
12216    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
12217    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
12218    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
12219    .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;}}
12220    .chart-select:focus{{border-color:var(--accent);}}
12221    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12222    .trend-canvas-wrap{{position:relative;height:260px;}}
12223    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12224    .site-footer a{{color:var(--muted);}}
12225    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
12226    .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;}}
12227    .btn:hover{{background:var(--surface-2);}}
12228    .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;}}
12229    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12230    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
12231    .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;}}
12232    .scope-sel:focus{{border-color:var(--accent);}}
12233    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
12234    .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;}}
12235    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12236    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12237    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12238    .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;}}
12239    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12240    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12241    .watched-chip-rm:hover{{color:var(--oxide);}}
12242    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12243    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12244    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12245    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12246    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
12247    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
12248    .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;}}
12249    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
12250    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
12251    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
12252    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
12253    .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;}}
12254    .cov-file-search:focus{{border-color:var(--accent);}}
12255    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
12256    .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;}}
12257    body.dark-theme .cov-file-search{{background:var(--surface);}}
12258    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
12259    .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;}}
12260    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12261    .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;}}
12262    .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);}}
12263    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
12264    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
12265    .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;}}
12266    .chart-modal-close:hover{{opacity:.7;}}
12267    body.dark-theme .chart-modal{{background:var(--surface);}}
12268  </style>
12269</head>
12270<body>
12271  <div class="background-watermarks" aria-hidden="true">
12272    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12273    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12274    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12275    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12276    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12277    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12278  </div>
12279  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12280  <div class="top-nav">
12281    <div class="top-nav-inner">
12282      <a class="brand" href="/">
12283        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12284        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
12285      </a>
12286      <div class="nav-right">
12287        <a class="nav-pill" href="/">Home</a>
12288        <div class="nav-dropdown">
12289          <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>
12290          <div class="nav-dropdown-menu">
12291            <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>
12292          </div>
12293        </div>
12294        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12295        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
12296        <div class="nav-dropdown">
12297          <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>
12298          <div class="nav-dropdown-menu">
12299            <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>
12300          </div>
12301        </div>
12302        <div class="server-status-wrap" id="server-status-wrap">
12303          <div class="nav-pill server-online-pill" id="server-status-pill">
12304            <span class="status-dot" id="status-dot"></span>
12305            <span id="server-status-label">Server</span>
12306            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12307          </div>
12308          <div class="server-status-tip">
12309            OxideSLOC is running — accessible on your network.
12310            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12311          </div>
12312        </div>
12313        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12314          <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>
12315        </button>
12316        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12317          <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>
12318          <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>
12319        </button>
12320      </div>
12321    </div>
12322  </div>
12323
12324  <div class="page">
12325    {watched_dirs_html}
12326    <div class="scope-bar">
12327      <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>
12328      <span class="scope-label">Scope</span>
12329      <div class="scope-sel-wrap">
12330        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
12331        <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);">
12332          <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>
12333          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
12334        </div>
12335      </div>
12336    </div>
12337    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12338      <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>
12339      <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>
12340      <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>
12341      <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>
12342    </div>
12343    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12344      <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>
12345      <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>
12346      <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>
12347      <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>
12348    </div>
12349
12350    <div class="panel" id="viz-panel">
12351      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
12352
12353      <div class="chart-box" style="margin-bottom:18px;">
12354        <div class="chart-box-header">
12355          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
12356          <div style="display:flex;gap:8px;align-items:center;">
12357            <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>
12358            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12359          </div>
12360        </div>
12361        <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare all scans side-by-side.</p>
12362        <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
12363        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
12364      </div>
12365
12366      <div class="chart-row">
12367        <div class="chart-box">
12368          <div class="chart-box-header">
12369            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
12370            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12371          </div>
12372          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
12373          <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>
12374        </div>
12375        <div class="chart-box">
12376          <div class="chart-box-header">
12377            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
12378            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12379          </div>
12380          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
12381          <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>
12382        </div>
12383      </div>
12384
12385      <div class="chart-row">
12386        <div class="chart-box">
12387          <div class="chart-box-header">
12388            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
12389            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12390          </div>
12391          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
12392          <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>
12393        </div>
12394        <div class="chart-box" id="suites-chart-box">
12395          <div class="chart-box-header">
12396            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
12397            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12398          </div>
12399          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
12400          <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>
12401        </div>
12402      </div>
12403
12404      <div class="chart-row">
12405        <div class="chart-box">
12406          <div class="chart-box-title">Test Files Breakdown</div>
12407          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
12408          <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>
12409        </div>
12410        <div class="chart-box">
12411          <div class="chart-box-title">Test Composition</div>
12412          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
12413          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
12414          <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>
12415        </div>
12416      </div>
12417    </div>
12418
12419    <div class="panel">
12420      <h1>Test Metrics</h1>
12421      <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>
12422
12423      <div class="section-header">Language Breakdown</div>
12424      {cov_no_data_notice}
12425      <div style="overflow-x:auto;">
12426        <table class="data-table" id="lang-table">
12427          <thead><tr>
12428            <th>Language</th>
12429            <th class="num">Test Fns</th>
12430            <th class="num">Assertions</th>
12431            <th class="num">Suites</th>
12432            <th class="num">Code Lines</th>
12433            <th class="num">Files</th>
12434            <th class="num">Density / 1K</th>
12435            <th>Relative Density</th>
12436          </tr></thead>
12437          <tbody id="lang-tbody"></tbody>
12438        </table>
12439      </div>
12440    </div>
12441
12442    <div class="panel" id="cov-panel" style="display:none;">
12443      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
12444      <div class="cov-gauge-row" id="cov-gauges">
12445        <div class="cov-gauge-card">
12446          <div class="cov-gauge-label">Line Coverage</div>
12447          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
12448          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
12449          <div class="cov-gauge-sub">Lines hit / instrumented</div>
12450        </div>
12451        <div class="cov-gauge-card">
12452          <div class="cov-gauge-label">Function Coverage</div>
12453          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
12454          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
12455          <div class="cov-gauge-sub">Functions hit / found</div>
12456        </div>
12457        <div class="cov-gauge-card">
12458          <div class="cov-gauge-label">Branch Coverage</div>
12459          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
12460          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
12461          <div class="cov-gauge-sub">Branches hit / found</div>
12462        </div>
12463      </div>
12464      <div class="chart-row">
12465        <div class="chart-box">
12466          <div class="chart-box-title">Line Coverage % by Language</div>
12467          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
12468        </div>
12469        <div class="chart-box">
12470          <div class="chart-box-title">Coverage Tier Distribution</div>
12471          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
12472        </div>
12473      </div>
12474
12475      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
12476      <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>
12477      <div class="cov-file-toolbar">
12478        <div class="cov-filter-tabs" id="cov-filter-tabs">
12479          <button class="cov-tab active" data-tier="all">All</button>
12480          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
12481          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
12482          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
12483          <button class="cov-tab" data-tier="high">High (≥80%)</button>
12484        </div>
12485        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
12486      </div>
12487      <div style="overflow-x:auto;">
12488        <table class="data-table" id="cov-file-table">
12489          <thead><tr>
12490            <th>File</th>
12491            <th>Lang</th>
12492            <th class="num">Line %</th>
12493            <th class="num">Lines Hit / Found</th>
12494            <th class="num">Fn %</th>
12495            <th class="num">Fns Hit / Found</th>
12496          </tr></thead>
12497          <tbody id="cov-file-tbody"></tbody>
12498        </table>
12499      </div>
12500      <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>
12501      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
12502    </div>
12503
12504  </div>
12505
12506  <footer class="site-footer">
12507    local code analysis - metrics, history and reports
12508    &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>
12509    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12510    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12511    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12512    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12513  </footer>
12514
12515  <script nonce="{nonce}">
12516  (function() {{
12517    // Theme
12518    var b = document.body;
12519    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12520    var tgl = document.getElementById('theme-toggle');
12521    if (tgl) tgl.addEventListener('click', function() {{
12522      var d = b.classList.toggle('dark-theme');
12523      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12524    }});
12525
12526    // Watermarks
12527    (function() {{
12528      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12529      if (!wms.length) return;
12530      var placed = [];
12531      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;}}
12532      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];}}
12533      var half=Math.floor(wms.length/2);
12534      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;}});
12535    }})();
12536
12537    // Code particles
12538    (function() {{
12539      var container = document.getElementById('code-particles');
12540      if (!container) return;
12541      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
12542      for (var i = 0; i < 36; i++) {{
12543        (function(idx) {{
12544          var el = document.createElement('span');
12545          el.className = 'code-particle';
12546          el.textContent = snippets[idx % snippets.length];
12547          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12548          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12549          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12550          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';
12551          container.appendChild(el);
12552        }})(i);
12553      }}
12554    }})();
12555
12556    // Settings modal
12557    (function() {{
12558      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'}}];
12559      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);}});}}
12560      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12561      var btn=document.getElementById('settings-btn');if(!btn)return;
12562      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12563      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>';
12564      document.body.appendChild(m);
12565      var g=document.getElementById('scheme-grid');
12566      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);}});
12567      var cl=document.getElementById('settings-close');
12568      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');}});
12569      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12570      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12571    }})();
12572
12573    // Watched folder picker
12574    (function() {{
12575      var btn = document.getElementById('add-watched-btn');
12576      if (!btn) return;
12577      btn.addEventListener('click', function() {{
12578        fetch('/pick-directory?kind=reports')
12579          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12580          .then(function(data) {{
12581            if (!data.cancelled && data.selected_path) {{
12582              var form = document.createElement('form');
12583              form.method = 'POST';
12584              form.action = '/watched-dirs/add';
12585              var ri = document.createElement('input');
12586              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12587              var fi = document.createElement('input');
12588              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12589              form.appendChild(ri); form.appendChild(fi);
12590              document.body.appendChild(form);
12591              form.submit();
12592            }}
12593          }})
12594          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12595      }});
12596    }})();
12597  }})();
12598  </script>
12599
12600  <script src="/static/chart.js" nonce="{nonce}"></script>
12601  <script nonce="{nonce}">
12602  (function() {{
12603    var SCOPE_DATA = {scope_data_json};
12604    var currentRoot = '__all__';
12605    var currentSub  = '';
12606    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
12607    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
12608    var ALL_CHARTS = [];
12609    var currentLangTests = [];
12610    var currentTrendPts = [];
12611
12612    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();}}
12613    function fmtFull(n){{return Number(n).toLocaleString();}}
12614    function isDark(){{return document.body.classList.contains('dark-theme');}}
12615    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
12616    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
12617    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
12618
12619    function makeDlPlugin(fmtFn, anchor) {{
12620      return {{
12621        afterDatasetsDraw: function(chart) {{
12622          var ctx = chart.ctx;
12623          var tc = txtClr();
12624          chart.data.datasets.forEach(function(ds, di) {{
12625            var meta = chart.getDatasetMeta(di);
12626            meta.data.forEach(function(el, idx) {{
12627              var label = fmtFn(ds.data[idx], di, idx);
12628              if (label == null || label === '') return;
12629              ctx.save();
12630              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
12631              ctx.fillStyle = tc;
12632              if (anchor === 'top') {{
12633                ctx.textAlign = 'center';
12634                ctx.textBaseline = 'bottom';
12635                ctx.fillText(String(label), el.x, el.y - 5);
12636              }} else {{
12637                ctx.textAlign = 'left';
12638                ctx.textBaseline = 'middle';
12639                ctx.fillText(String(label), el.x + 5, el.y);
12640              }}
12641              ctx.restore();
12642            }});
12643          }});
12644        }}
12645      }};
12646    }}
12647
12648    function makeTmOverlay(title, subtitle, h) {{
12649      var overlay = document.createElement('div');
12650      overlay.className = 'chart-modal-overlay';
12651      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
12652      var ch = Math.min(h || 560, maxH);
12653      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
12654      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>';
12655      document.body.appendChild(overlay);
12656      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
12657      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
12658      return document.getElementById('tm-modal-canvas');
12659    }}
12660
12661    function getDataset() {{
12662      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
12663      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
12664      return r;
12665    }}
12666    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
12667
12668    function showNoData(id, show) {{
12669      var el = document.getElementById(id);
12670      if (!el) return;
12671      var wrap = el.previousElementSibling;
12672      el.style.display = show ? '' : 'none';
12673      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
12674    }}
12675
12676    function renderTestCharts(D) {{
12677      currentLangTests = D || [];
12678      testsChart = destroyChart(testsChart);
12679      densityChart = destroyChart(densityChart);
12680      if (!D || !D.length) {{
12681        showNoData('no-data-tests', true);
12682        showNoData('no-data-density', true);
12683        return;
12684      }}
12685      showNoData('no-data-tests', false);
12686      showNoData('no-data-density', false);
12687      var top15 = D.slice(0, 15);
12688      var canvas1 = document.getElementById('canvas-tests');
12689      if (canvas1) {{
12690        testsChart = new Chart(canvas1, {{
12691          type: 'bar',
12692          data: {{
12693            labels: top15.map(function(d){{ return d.lang; }}),
12694            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
12695          }},
12696          options: {{
12697            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12698            layout: {{ padding: {{ right: 64 }} }},
12699            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12700            scales: {{
12701              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12702              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12703            }}
12704          }},
12705          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12706        }});
12707        ALL_CHARTS.push(testsChart);
12708      }}
12709      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
12710      var canvas2 = document.getElementById('canvas-density');
12711      if (canvas2) {{
12712        densityChart = new Chart(canvas2, {{
12713          type: 'bar',
12714          data: {{
12715            labels: topD.map(function(d){{ return d.lang; }}),
12716            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 }}]
12717          }},
12718          options: {{
12719            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12720            layout: {{ padding: {{ right: 64 }} }},
12721            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
12722            scales: {{
12723              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
12724              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12725            }}
12726          }},
12727          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
12728        }});
12729        ALL_CHARTS.push(densityChart);
12730      }}
12731    }}
12732
12733    function renderAssertionsChart(D) {{
12734      assertionsChart = destroyChart(assertionsChart);
12735      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
12736      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
12737      var canvas = document.getElementById('canvas-assertions');
12738      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
12739      showNoData('no-data-assertions', false);
12740      assertionsChart = new Chart(canvas, {{
12741        type: 'bar',
12742        data: {{
12743          labels: top15.map(function(d){{ return d.lang; }}),
12744          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
12745        }},
12746        options: {{
12747          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12748          layout: {{ padding: {{ right: 64 }} }},
12749          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12750          scales: {{
12751            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12752            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12753          }}
12754        }},
12755        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12756      }});
12757      ALL_CHARTS.push(assertionsChart);
12758    }}
12759
12760    function renderSuitesChart(D) {{
12761      suitesChart = destroyChart(suitesChart);
12762      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
12763      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
12764      var canvas = document.getElementById('canvas-suites');
12765      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
12766      showNoData('no-data-suites', false);
12767      suitesChart = new Chart(canvas, {{
12768        type: 'bar',
12769        data: {{
12770          labels: top15.map(function(d){{ return d.lang; }}),
12771          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 }}]
12772        }},
12773        options: {{
12774          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12775          layout: {{ padding: {{ right: 64 }} }},
12776          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12777          scales: {{
12778            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12779            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12780          }}
12781        }},
12782        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12783      }});
12784      ALL_CHARTS.push(suitesChart);
12785    }}
12786
12787    function renderFilesChart(totals) {{
12788      filesChart = destroyChart(filesChart);
12789      var canvas = document.getElementById('canvas-files');
12790      if (!canvas) return;
12791      var testF = totals.test_files || 0;
12792      var totalF = totals.total_files || 0;
12793      var nonTest = Math.max(0, totalF - testF);
12794      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
12795      showNoData('no-data-files', false);
12796      var dark = isDark();
12797      filesChart = new Chart(canvas, {{
12798        type: 'doughnut',
12799        data: {{
12800          labels: ['Test Files', 'Non-Test Files'],
12801          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
12802        }},
12803        options: {{
12804          responsive: true, maintainAspectRatio: false, cutout: '62%',
12805          plugins: {{
12806            legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12807            tooltip: {{ callbacks: {{ label: function(ctx) {{
12808              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
12809              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
12810            }} }} }}
12811          }}
12812        }}
12813      }});
12814      ALL_CHARTS.push(filesChart);
12815    }}
12816
12817    function renderCompositionChart(totals) {{
12818      compositionChart = destroyChart(compositionChart);
12819      var canvas = document.getElementById('canvas-composition');
12820      if (!canvas) return;
12821      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
12822      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
12823      showNoData('no-data-composition', false);
12824      compositionChart = new Chart(canvas, {{
12825        type: 'bar',
12826        data: {{
12827          labels: ['Test Functions', 'Assertions', 'Test Suites'],
12828          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
12829        }},
12830        options: {{
12831          responsive: true, maintainAspectRatio: false,
12832          layout: {{ padding: {{ top: 22 }} }},
12833          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
12834          scales: {{
12835            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
12836            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
12837          }}
12838        }},
12839        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
12840      }});
12841      ALL_CHARTS.push(compositionChart);
12842    }}
12843
12844    function renderCovCharts(covD, tiers) {{
12845      covChart = destroyChart(covChart);
12846      tierChart = destroyChart(tierChart);
12847      var covCanvas = document.getElementById('canvas-cov');
12848      if (covCanvas && covD && covD.length) {{
12849        covChart = new Chart(covCanvas, {{
12850          type: 'bar',
12851          data: {{
12852            labels: covD.map(function(d){{ return d.lang; }}),
12853            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 }}]
12854          }},
12855          options: {{
12856            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12857            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
12858            scales: {{
12859              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
12860              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12861            }}
12862          }}
12863        }});
12864        ALL_CHARTS.push(covChart);
12865      }}
12866      var tierCanvas = document.getElementById('canvas-cov-tiers');
12867      if (tierCanvas && tiers) {{
12868        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
12869        tierChart = new Chart(tierCanvas, {{
12870          type: 'doughnut',
12871          data: {{
12872            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
12873            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
12874          }},
12875          options: {{
12876            responsive: true, maintainAspectRatio: false, cutout: '62%',
12877            plugins: {{
12878              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12879              tooltip: {{ callbacks: {{ label: function(ctx) {{
12880                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
12881                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
12882              }} }} }}
12883            }}
12884          }}
12885        }});
12886        ALL_CHARTS.push(tierChart);
12887      }}
12888    }}
12889
12890    function buildLangTable(D) {{
12891      var tbody = document.getElementById('lang-tbody');
12892      if (!tbody) return;
12893      if (!D || !D.length) {{
12894        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>';
12895        return;
12896      }}
12897      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
12898      tbody.innerHTML = D.map(function(d) {{
12899        var barW = Math.round(d.density / maxDensity * 120);
12900        return '<tr>' +
12901          '<td><strong>' + d.lang + '</strong></td>' +
12902          '<td class="num">' + fmt(d.tests) + '</td>' +
12903          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
12904          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
12905          '<td class="num">' + fmt(d.code) + '</td>' +
12906          '<td class="num">' + fmt(d.files) + '</td>' +
12907          '<td class="num">' + d.density.toFixed(2) + '</td>' +
12908          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
12909          '</tr>';
12910      }}).join('');
12911    }}
12912
12913    var covFileData = [];
12914    var covFileTier = 'all';
12915    var covFileSearch = '';
12916
12917    function pctBadge(pct) {{
12918      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
12919      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
12920      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
12921    }}
12922
12923    function buildCovFileTable() {{
12924      var tbody = document.getElementById('cov-file-tbody');
12925      var empty = document.getElementById('cov-file-empty');
12926      var count = document.getElementById('cov-file-count');
12927      if (!tbody) return;
12928      var srch = covFileSearch.toLowerCase();
12929      var filtered = covFileData.filter(function(f) {{
12930        if (covFileTier === 'zero' && f.line_pct > 0) return false;
12931        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
12932        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
12933        if (covFileTier === 'high' && f.line_pct < 80) return false;
12934        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
12935        return true;
12936      }});
12937      if (!filtered.length) {{
12938        tbody.innerHTML = '';
12939        if (empty) empty.style.display = '';
12940        if (count) count.textContent = '';
12941        return;
12942      }}
12943      if (empty) empty.style.display = 'none';
12944      var shown = Math.min(filtered.length, 500);
12945      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
12946      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
12947        var fnCol = f.fn_pct < 0
12948          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
12949          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
12950        return '<tr>' +
12951          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
12952          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
12953          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
12954          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
12955          fnCol +
12956          '</tr>';
12957      }}).join('');
12958    }}
12959
12960    (function() {{
12961      var tabs = document.getElementById('cov-filter-tabs');
12962      if (tabs) {{
12963        tabs.addEventListener('click', function(e) {{
12964          var btn = e.target.closest('.cov-tab');
12965          if (!btn) return;
12966          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
12967          btn.classList.add('active');
12968          covFileTier = btn.getAttribute('data-tier');
12969          buildCovFileTable();
12970        }});
12971      }}
12972      var srch = document.getElementById('cov-file-search');
12973      if (srch) {{
12974        srch.addEventListener('input', function() {{
12975          covFileSearch = this.value;
12976          buildCovFileTable();
12977        }});
12978      }}
12979    }})();
12980
12981    function updateCovGauges(t) {{
12982      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
12983      var el;
12984      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
12985      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
12986      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
12987      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
12988      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
12989      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
12990    }}
12991
12992    function applyScope() {{
12993      var d = getDataset();
12994      var t = d.totals;
12995      var el;
12996      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
12997      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
12998      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
12999      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
13000      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
13001      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
13002      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
13003      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
13004      renderTestCharts(d.lang_tests);
13005      renderAssertionsChart(d.lang_tests);
13006      renderSuitesChart(d.lang_tests);
13007      renderFilesChart(t);
13008      renderCompositionChart(t);
13009      buildLangTable(d.lang_tests);
13010      var covPanel = document.getElementById('cov-panel');
13011      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
13012      if (d.has_coverage) {{
13013        renderCovCharts(d.cov, d.cov_tiers);
13014        updateCovGauges(t);
13015        covFileData = d.file_cov || [];
13016        covFileTier = 'all';
13017        covFileSearch = '';
13018        var tabs = document.getElementById('cov-filter-tabs');
13019        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
13020        var srch = document.getElementById('cov-file-search');
13021        if (srch) srch.value = '';
13022        buildCovFileTable();
13023      }}
13024      loadTrend();
13025    }}
13026
13027    // Populate scope-root-sel from SCOPE_DATA keys
13028    (function() {{
13029      var sel = document.getElementById('scope-root-sel');
13030      if (!sel) return;
13031      Object.keys(SCOPE_DATA).forEach(function(k) {{
13032        if (k === '__all__') return;
13033        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
13034      }});
13035    }})();
13036
13037    document.getElementById('scope-root-sel').addEventListener('change', function() {{
13038      currentRoot = this.value;
13039      currentSub = '';
13040      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
13041      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
13042      var subWrap = document.getElementById('scope-sub-wrap');
13043      var subSel  = document.getElementById('scope-sub-sel');
13044      subSel.innerHTML = '<option value="">Entire project</option>';
13045      if (subNames.length) {{
13046        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
13047        subWrap.style.display = 'flex';
13048      }} else {{
13049        subWrap.style.display = 'none';
13050      }}
13051      applyScope();
13052    }});
13053
13054    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
13055      currentSub = this.value;
13056      applyScope();
13057    }});
13058
13059    function buildTrend(data) {{
13060      var trendCanvas = document.getElementById('canvas-trend');
13061      var trendEmpty  = document.getElementById('trend-empty');
13062      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
13063      pts = pts.slice().reverse();
13064      currentTrendPts = pts;
13065      if (!pts.length) {{
13066        if (trendCanvas) trendCanvas.style.display = 'none';
13067        if (trendEmpty) trendEmpty.style.display = '';
13068        return;
13069      }}
13070      if (trendCanvas) trendCanvas.style.display = '';
13071      if (trendEmpty) trendEmpty.style.display = 'none';
13072      trendChart = destroyChart(trendChart);
13073      if (!trendCanvas) return;
13074      trendChart = new Chart(trendCanvas, {{
13075        type: 'line',
13076        data: {{
13077          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13078          datasets: [{{
13079            label: 'Test Definitions',
13080            data: pts.map(function(d){{ return d.test_count; }}),
13081            borderColor: '#C45C10',
13082            backgroundColor: 'rgba(196,92,16,0.10)',
13083            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13084            pointRadius: 5, fill: true, tension: 0.3
13085          }}]
13086        }},
13087        options: {{
13088          responsive: true, maintainAspectRatio: false,
13089          layout: {{ padding: {{ top: 22 }} }},
13090          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13091          scales: {{
13092            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
13093            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13094          }}
13095        }},
13096        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13097      }});
13098      ALL_CHARTS.push(trendChart);
13099    }}
13100
13101    // ── Full View expand buttons ──────────────────────────────────────────────
13102    (function() {{
13103      var btn = document.getElementById('tests-expand-btn');
13104      if (!btn) return;
13105      btn.addEventListener('click', function() {{
13106        var D = currentLangTests;
13107        if (!D || !D.length) return;
13108        var top15 = D.slice(0, 15);
13109        var h = Math.max(320, top15.length * 36 + 80);
13110        var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
13111        if (!canvas) return;
13112        new Chart(canvas, {{
13113          type: 'bar',
13114          data: {{
13115            labels: top15.map(function(d){{ return d.lang; }}),
13116            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
13117          }},
13118          options: {{
13119            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13120            layout: {{ padding: {{ right: 72 }} }},
13121            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13122            scales: {{
13123              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13124              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13125            }}
13126          }},
13127          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13128        }});
13129      }});
13130    }})();
13131
13132    (function() {{
13133      var btn = document.getElementById('density-expand-btn');
13134      if (!btn) return;
13135      btn.addEventListener('click', function() {{
13136        var D = currentLangTests;
13137        if (!D || !D.length) return;
13138        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
13139        var h = Math.max(320, topD.length * 36 + 80);
13140        var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
13141        if (!canvas) return;
13142        new Chart(canvas, {{
13143          type: 'bar',
13144          data: {{
13145            labels: topD.map(function(d){{ return d.lang; }}),
13146            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 }}]
13147          }},
13148          options: {{
13149            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13150            layout: {{ padding: {{ right: 72 }} }},
13151            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
13152            scales: {{
13153              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
13154              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13155            }}
13156          }},
13157          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
13158        }});
13159      }});
13160    }})();
13161
13162    (function() {{
13163      var btn = document.getElementById('trend-expand-btn');
13164      if (!btn) return;
13165      btn.addEventListener('click', function() {{
13166        var pts = currentTrendPts;
13167        if (!pts || !pts.length) return;
13168        var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
13169        if (!canvas) return;
13170        new Chart(canvas, {{
13171          type: 'line',
13172          data: {{
13173            labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13174            datasets: [{{
13175              label: 'Test Definitions',
13176              data: pts.map(function(d){{ return d.test_count; }}),
13177              borderColor: '#C45C10',
13178              backgroundColor: 'rgba(196,92,16,0.10)',
13179              pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13180              pointRadius: 5, fill: true, tension: 0.3
13181            }}]
13182          }},
13183          options: {{
13184            responsive: true, maintainAspectRatio: false,
13185            layout: {{ padding: {{ top: 22 }} }},
13186            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13187            scales: {{
13188              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
13189              y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13190            }}
13191          }},
13192          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13193        }});
13194      }});
13195    }})();
13196
13197    (function() {{
13198      var btn = document.getElementById('assertions-expand-btn');
13199      if (!btn) return;
13200      btn.addEventListener('click', function() {{
13201        var D = currentLangTests;
13202        if (!D || !D.length) return;
13203        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
13204        if (!top15.length) return;
13205        var h = Math.max(320, top15.length * 36 + 80);
13206        var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
13207        if (!canvas) return;
13208        new Chart(canvas, {{
13209          type: 'bar',
13210          data: {{
13211            labels: top15.map(function(d){{ return d.lang; }}),
13212            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
13213          }},
13214          options: {{
13215            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13216            layout: {{ padding: {{ right: 72 }} }},
13217            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13218            scales: {{
13219              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13220              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13221            }}
13222          }},
13223          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13224        }});
13225      }});
13226    }})();
13227
13228    (function() {{
13229      var btn = document.getElementById('suites-expand-btn');
13230      if (!btn) return;
13231      btn.addEventListener('click', function() {{
13232        var D = currentLangTests;
13233        if (!D || !D.length) return;
13234        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
13235        if (!top15.length) return;
13236        var h = Math.max(320, top15.length * 36 + 80);
13237        var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
13238        if (!canvas) return;
13239        new Chart(canvas, {{
13240          type: 'bar',
13241          data: {{
13242            labels: top15.map(function(d){{ return d.lang; }}),
13243            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 }}]
13244          }},
13245          options: {{
13246            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13247            layout: {{ padding: {{ right: 72 }} }},
13248            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13249            scales: {{
13250              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13251              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13252            }}
13253          }},
13254          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13255        }});
13256      }});
13257    }})();
13258
13259    function loadTrend() {{
13260      var url = '/api/metrics/history?limit=100';
13261      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
13262      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
13263        buildTrend(data);
13264        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
13265        var btn = document.getElementById('multi-compare-trend-btn');
13266        if (btn) {{
13267          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
13268          if (ids.length >= 2) {{
13269            btn.style.display = '';
13270            btn.onclick = function() {{
13271              // Reverse so oldest first (API returns newest first).
13272              var sorted = ids.slice().reverse();
13273              if (sorted.length === 2) {{
13274                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
13275              }} else {{
13276                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
13277              }}
13278            }};
13279          }} else {{
13280            btn.style.display = 'none';
13281          }}
13282        }}
13283      }}).catch(function(){{
13284        var trendEmpty = document.getElementById('trend-empty');
13285        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
13286      }});
13287    }}
13288
13289    // Re-render charts on theme toggle
13290    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
13291      setTimeout(function() {{
13292        ALL_CHARTS.forEach(function(c) {{
13293          if (c && c.options && c.options.scales) {{
13294            Object.values(c.options.scales).forEach(function(ax) {{
13295              if (ax.grid) ax.grid.color = clr();
13296              if (ax.ticks) ax.ticks.color = txtClr();
13297            }});
13298            c.update();
13299          }}
13300        }});
13301      }}, 80);
13302    }});
13303
13304    applyScope();
13305  }})();
13306  </script>
13307  <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} — 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>
13308</body>
13309</html>"#,
13310    );
13311    Html(html).into_response()
13312}
13313
13314// ── Embeddable widget ─────────────────────────────────────────────────────────
13315// Protected. Returns a self-contained HTML page suitable for iframing inside
13316// Jenkins build summaries, Confluence iframe macros, or Jira panels.
13317//
13318// GET /embed/summary?run_id=<uuid>&theme=dark
13319
13320#[derive(Deserialize)]
13321struct EmbedQuery {
13322    run_id: Option<String>,
13323    theme: Option<String>,
13324}
13325
13326async fn embed_handler(
13327    State(state): State<AppState>,
13328    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13329    Query(query): Query<EmbedQuery>,
13330) -> Response {
13331    let entry = {
13332        let reg = state.registry.lock().await;
13333        query.run_id.as_ref().map_or_else(
13334            || reg.entries.first().cloned(),
13335            |id| reg.find_by_run_id(id).cloned(),
13336        )
13337    };
13338
13339    let Some(entry) = entry else {
13340        return Html(
13341            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
13342                .to_string(),
13343        )
13344        .into_response();
13345    };
13346
13347    let dark = query.theme.as_deref() == Some("dark");
13348    let languages: Vec<(String, u64, u64)> = entry
13349        .json_path
13350        .as_ref()
13351        .and_then(|p| read_json(p).ok())
13352        .map(|run| {
13353            run.totals_by_language
13354                .iter()
13355                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
13356                .collect()
13357        })
13358        .unwrap_or_default();
13359
13360    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
13361}
13362
13363fn render_embed_widget(
13364    entry: &RegistryEntry,
13365    languages: &[(String, u64, u64)],
13366    dark: bool,
13367    csp_nonce: &str,
13368) -> String {
13369    let s = &entry.summary;
13370    let total = s.code_lines + s.comment_lines + s.blank_lines;
13371    let code_pct = s
13372        .code_lines
13373        .checked_mul(100)
13374        .and_then(|n| n.checked_div(total))
13375        .unwrap_or(0);
13376
13377    let (bg, fg, surface, muted, border) = if dark {
13378        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
13379    } else {
13380        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
13381    };
13382
13383    let mut lang_rows = String::new();
13384    for (name, files, code) in languages {
13385        write!(
13386            lang_rows,
13387            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
13388            escape_html(name),
13389            format_number(*files),
13390            format_number(*code),
13391        )
13392        .ok();
13393    }
13394
13395    let lang_table = if lang_rows.is_empty() {
13396        String::new()
13397    } else {
13398        format!(
13399            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
13400        )
13401    };
13402
13403    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
13404    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
13405    let project_esc = escape_html(&entry.project_label);
13406    let code_lines = format_number(s.code_lines);
13407    let comment_lines = format_number(s.comment_lines);
13408    let files = format_number(s.files_analyzed);
13409    let code_raw = s.code_lines;
13410    let comment_raw = s.comment_lines;
13411    let blank_raw = s.blank_lines;
13412
13413    format!(
13414        r#"<!doctype html>
13415<html lang="en">
13416<head>
13417  <meta charset="utf-8">
13418  <meta name="viewport" content="width=device-width,initial-scale=1">
13419  <title>OxideSLOC &mdash; {project_esc}</title>
13420  <script src="/static/chart.js"></script>
13421  <style nonce="{csp_nonce}">
13422    *{{box-sizing:border-box;margin:0;padding:0}}
13423    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
13424    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
13425    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
13426    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
13427    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
13428    .card .v{{font-size:18px;font-weight:700}}
13429    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
13430    .row{{display:flex;gap:12px;align-items:flex-start}}
13431    .pie{{width:120px;height:120px;flex-shrink:0}}
13432    .lt{{border-collapse:collapse;width:100%;flex:1}}
13433    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
13434    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
13435    .n{{text-align:right}}
13436    .footer{{margin-top:10px;color:{muted};font-size:10px}}
13437  </style>
13438</head>
13439<body>
13440  <h2>{project_esc}</h2>
13441  <div class="sub">{timestamp} &middot; run {run_short}</div>
13442  <div class="cards">
13443    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
13444    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
13445    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
13446    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
13447  </div>
13448  <div class="row">
13449    <canvas class="pie" id="c"></canvas>
13450    {lang_table}
13451  </div>
13452  <div class="footer">oxide-sloc</div>
13453  <script nonce="{csp_nonce}">
13454    new Chart(document.getElementById('c'),{{
13455      type:'doughnut',
13456      data:{{
13457        labels:['Code','Comments','Blank'],
13458        datasets:[{{
13459          data:[{code_raw},{comment_raw},{blank_raw}],
13460          backgroundColor:['#4a78ee','#b35428','#aaa'],
13461          borderWidth:0
13462        }}]
13463      }},
13464      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
13465    }});
13466  </script>
13467</body>
13468</html>"#
13469    )
13470}
13471
13472/// Returns a process-wide mutex unique to `dir`, so that two requests writing
13473/// artifacts into the *same* output directory (e.g. re-ingesting an identical
13474/// `run_id`) serialize instead of corrupting each other's files. Directories that
13475/// differ never contend, so legitimate parallel analyses keep their throughput.
13476fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
13477    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
13478        OnceLock::new();
13479    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
13480    let mut guard = map
13481        .lock()
13482        .unwrap_or_else(std::sync::PoisonError::into_inner);
13483    guard
13484        .entry(dir.to_path_buf())
13485        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
13486        .clone()
13487}
13488
13489#[allow(clippy::too_many_lines)]
13490fn persist_run_artifacts(
13491    run: &sloc_core::AnalysisRun,
13492    report_html: &str,
13493    run_dir: &Path,
13494    report_title: &str,
13495    file_stem: &str,
13496    result_context: RunResultContext,
13497) -> Result<(RunArtifacts, PendingPdf)> {
13498    // Serialize concurrent writers targeting this same output directory so their
13499    // file writes cannot interleave and corrupt one another.
13500    let dir_lock = output_dir_lock(run_dir);
13501    let _dir_guard = dir_lock
13502        .lock()
13503        .unwrap_or_else(std::sync::PoisonError::into_inner);
13504
13505    // Root dir + organised subdirectories.
13506    let html_dir = run_dir.join("html");
13507    let pdf_dir = run_dir.join("pdf");
13508    let excel_dir = run_dir.join("excel");
13509    let json_dir = run_dir.join("json");
13510    let submodules_dir = run_dir.join("submodules");
13511    for dir in &[
13512        run_dir,
13513        &html_dir,
13514        &pdf_dir,
13515        &excel_dir,
13516        &json_dir,
13517        &submodules_dir,
13518    ] {
13519        fs::create_dir_all(dir)
13520            .with_context(|| format!("failed to create directory {}", dir.display()))?;
13521    }
13522
13523    // HTML report in html/.
13524    let html_path = {
13525        let path = html_dir.join(format!("report_{file_stem}.html"));
13526        fs::write(&path, report_html)
13527            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
13528        Some(path)
13529    };
13530
13531    // JSON result in json/.
13532    let json_path = {
13533        let path = json_dir.join(format!("result_{file_stem}.json"));
13534        let json = serde_json::to_string_pretty(run)
13535            .context("failed to serialize analysis run to JSON")?;
13536        fs::write(&path, json)
13537            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
13538        Some(path)
13539    };
13540
13541    // PDF in pdf/.
13542    let (pdf_path, pending_pdf) = {
13543        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
13544        match write_pdf_from_run(run, &pdf_dest) {
13545            Ok(()) => {
13546                eprintln!(
13547                    "[oxide-sloc][pdf] native PDF written to {}",
13548                    pdf_dest.display()
13549                );
13550                (Some(pdf_dest), None)
13551            }
13552            Err(native_err) => {
13553                eprintln!(
13554                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
13555                );
13556                let source_html_path = html_path
13557                    .as_ref()
13558                    .expect("html_path always Some here")
13559                    .clone();
13560                let pending = Some((source_html_path, pdf_dest.clone(), false));
13561                (Some(pdf_dest), pending)
13562            }
13563        }
13564    };
13565
13566    // CSV and XLSX in excel/.
13567    let csv_path = {
13568        let path = excel_dir.join(format!("report_{file_stem}.csv"));
13569        if let Err(e) = sloc_report::write_csv(run, &path) {
13570            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
13571            None
13572        } else {
13573            Some(path)
13574        }
13575    };
13576
13577    let xlsx_path = {
13578        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
13579        if let Err(e) = sloc_report::write_xlsx(run, &path) {
13580            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
13581            None
13582        } else {
13583            Some(path)
13584        }
13585    };
13586
13587    // Scan config in json/.
13588    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
13589
13590    // Eagerly generate sub-reports before index.html so relative links work.
13591    if run.effective_configuration.discovery.submodule_breakdown {
13592        let run_id = &run.tool.run_id;
13593        for s in &run.submodule_summaries {
13594            build_submodule_row(s, run, run_id, run_dir);
13595        }
13596    }
13597
13598    // index.html at root — offline static export of the result-page dashboard.
13599    generate_offline_index(
13600        run,
13601        run_dir,
13602        file_stem,
13603        html_path.as_deref(),
13604        pdf_path.as_deref(),
13605        json_path.as_deref(),
13606        scan_config_path.as_deref(),
13607        &result_context,
13608    );
13609
13610    Ok((
13611        RunArtifacts {
13612            output_dir: run_dir.to_path_buf(),
13613            html_path,
13614            pdf_path,
13615            json_path,
13616            csv_path,
13617            xlsx_path,
13618            scan_config_path,
13619            report_title: report_title.to_string(),
13620            result_context,
13621        },
13622        pending_pdf,
13623    ))
13624}
13625
13626/// Render a static offline result-page dashboard and write it as `index.html` at
13627/// the root of the run output directory so business users can open it from disk.
13628#[allow(clippy::too_many_arguments)]
13629#[allow(clippy::too_many_lines)]
13630#[allow(clippy::similar_names)]
13631fn generate_offline_index(
13632    run: &sloc_core::AnalysisRun,
13633    run_dir: &Path,
13634    file_stem: &str,
13635    html_path: Option<&Path>,
13636    pdf_path: Option<&Path>,
13637    json_path: Option<&Path>,
13638    scan_config_path: Option<&Path>,
13639    result_context: &RunResultContext,
13640) {
13641    let prev_entry = &result_context.prev_entry;
13642    let prev_scan_count = result_context.prev_scan_count;
13643    let project_path = &result_context.project_path;
13644
13645    let scan_delta = prev_entry.as_ref().and_then(|prev| {
13646        prev.json_path
13647            .as_ref()
13648            .and_then(|p| read_json(p).ok())
13649            .map(|prev_run| compute_delta(&prev_run, run))
13650    });
13651
13652    let files_analyzed = run.per_file_records.len() as u64;
13653    let files_skipped = run.skipped_file_records.len() as u64;
13654    let physical_lines = run
13655        .totals_by_language
13656        .iter()
13657        .map(|r| r.total_physical_lines)
13658        .sum::<u64>();
13659    let code_lines = run
13660        .totals_by_language
13661        .iter()
13662        .map(|r| r.code_lines)
13663        .sum::<u64>();
13664    let comment_lines = run
13665        .totals_by_language
13666        .iter()
13667        .map(|r| r.comment_lines)
13668        .sum::<u64>();
13669    let blank_lines = run
13670        .totals_by_language
13671        .iter()
13672        .map(|r| r.blank_lines)
13673        .sum::<u64>();
13674    let mixed_lines = run
13675        .totals_by_language
13676        .iter()
13677        .map(|r| r.mixed_lines_separate)
13678        .sum::<u64>();
13679    let functions = run
13680        .totals_by_language
13681        .iter()
13682        .map(|r| r.functions)
13683        .sum::<u64>();
13684    let classes = run
13685        .totals_by_language
13686        .iter()
13687        .map(|r| r.classes)
13688        .sum::<u64>();
13689    let variables = run
13690        .totals_by_language
13691        .iter()
13692        .map(|r| r.variables)
13693        .sum::<u64>();
13694    let imports = run
13695        .totals_by_language
13696        .iter()
13697        .map(|r| r.imports)
13698        .sum::<u64>();
13699
13700    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
13701    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
13702    let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
13703    let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
13704    let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
13705    let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
13706    let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
13707    let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
13708
13709    let (delta_fa_str, delta_fa_class) =
13710        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
13711    let (delta_fs_str, delta_fs_class) =
13712        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
13713    let (delta_pl_str, delta_pl_class) =
13714        summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
13715    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
13716    let (delta_cml_str, delta_cml_class) =
13717        summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
13718    let (delta_bl_str, delta_bl_class) =
13719        summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
13720
13721    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
13722    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
13723    let (delta_lines_net_str, delta_lines_net_class) =
13724        match (delta_lines_added, delta_lines_removed) {
13725            (Some(a), Some(r)) => {
13726                let net = a - r;
13727                (fmt_delta(net), delta_class(net).to_string())
13728            }
13729            _ => ("\u{2014}".to_string(), "na".to_string()),
13730        };
13731
13732    let git_commit_url = run
13733        .git_remote_url
13734        .as_deref()
13735        .zip(run.git_commit_long.as_deref())
13736        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
13737    let git_branch_url = run
13738        .git_remote_url
13739        .as_deref()
13740        .zip(run.git_branch.as_deref())
13741        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
13742    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
13743        format!(
13744            "{} / {}",
13745            run.environment.initiator_username, run.environment.initiator_hostname
13746        )
13747    });
13748
13749    // Convert absolute path to relative from run_dir (for file:// navigation).
13750    let make_rel = |p: Option<&Path>| -> Option<String> {
13751        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
13752            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
13753    };
13754
13755    let run_id = &run.tool.run_id;
13756
13757    // Submodule rows with relative paths into submodules/.
13758    let submodule_rows: Vec<SubmoduleRow> = run
13759        .submodule_summaries
13760        .iter()
13761        .map(|s| {
13762            let safe = sanitize_project_label(&s.name);
13763            let key = format!("sub_{safe}");
13764            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
13765            SubmoduleRow {
13766                name: s.name.clone(),
13767                relative_path: s.relative_path.clone(),
13768                files_analyzed: s.files_analyzed,
13769                code_lines: s.code_lines,
13770                comment_lines: s.comment_lines,
13771                blank_lines: s.blank_lines,
13772                total_physical_lines: s.total_physical_lines,
13773                html_url: if sub_path.exists() {
13774                    Some(format!("submodules/{key}.html"))
13775                } else {
13776                    None
13777                },
13778            }
13779        })
13780        .collect();
13781
13782    let lang_chart_json = {
13783        let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
13784        langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
13785        let entries: Vec<String> = langs
13786            .into_iter()
13787            .take(12)
13788            .map(|l| {
13789                let name = l.language.display_name()
13790                    .replace('\\', "\\\\")
13791                    .replace('"', "\\\"");
13792                format!(
13793                    r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
13794                    name, l.code_lines, l.comment_lines, l.blank_lines,
13795                    l.total_physical_lines, l.functions, l.classes,
13796                    l.variables, l.imports, l.files
13797                )
13798            })
13799            .collect();
13800        format!("[{}]", entries.join(","))
13801    };
13802
13803    let scan_config_rel =
13804        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
13805
13806    let template = ResultTemplate {
13807        version: env!("CARGO_PKG_VERSION"),
13808        report_title: run.effective_configuration.reporting.report_title.clone(),
13809        project_path: project_path.clone(),
13810        output_dir: display_path(run_dir),
13811        run_id: run_id.clone(),
13812        run_id_short: run_id
13813            .split('-')
13814            .next_back()
13815            .unwrap_or(run_id)
13816            .chars()
13817            .take(7)
13818            .collect(),
13819        files_analyzed,
13820        files_skipped,
13821        physical_lines,
13822        code_lines,
13823        comment_lines,
13824        blank_lines,
13825        mixed_lines,
13826        functions,
13827        classes,
13828        variables,
13829        imports,
13830        html_url: make_rel(html_path),
13831        pdf_url: make_rel(pdf_path),
13832        json_url: make_rel(json_path),
13833        html_download_url: make_rel(html_path),
13834        pdf_download_url: make_rel(pdf_path),
13835        json_download_url: make_rel(json_path),
13836        html_path: html_path.map(display_path),
13837        json_path: json_path.map(display_path),
13838        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
13839        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
13840        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
13841        prev_fa_str,
13842        prev_fs_str,
13843        prev_pl_str,
13844        prev_cl_str,
13845        prev_cml_str,
13846        prev_bl_str,
13847        delta_fa_str,
13848        delta_fa_class: delta_fa_class.to_string(),
13849        delta_fs_str,
13850        delta_fs_class: delta_fs_class.to_string(),
13851        delta_pl_str,
13852        delta_pl_class: delta_pl_class.to_string(),
13853        delta_cl_str,
13854        delta_cl_class: delta_cl_class.to_string(),
13855        delta_cml_str,
13856        delta_cml_class: delta_cml_class.to_string(),
13857        delta_bl_str,
13858        delta_bl_class: delta_bl_class.to_string(),
13859        delta_lines_added,
13860        delta_lines_removed,
13861        delta_lines_net_str,
13862        delta_lines_net_class,
13863        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
13864        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
13865        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
13866        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
13867        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
13868            d.file_deltas
13869                .iter()
13870                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
13871                .map(|f| {
13872                    #[allow(clippy::cast_sign_loss)]
13873                    let n = f.current_code as u64;
13874                    n
13875                })
13876                .sum()
13877        }),
13878        git_branch: run.git_branch.clone(),
13879        git_branch_url,
13880        git_commit: run.git_commit_short.clone(),
13881        git_commit_long: run.git_commit_long.clone(),
13882        git_author: run.git_commit_author.clone(),
13883        git_commit_url,
13884        scan_performed_by,
13885        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
13886        os_display: format!(
13887            "{} / {}",
13888            run.environment.operating_system, run.environment.architecture
13889        ),
13890        test_count: run.summary_totals.test_count,
13891        current_scan_number: prev_scan_count + 1,
13892        prev_scan_count,
13893        submodule_rows,
13894        pdf_generating: false,
13895        scan_config_url: scan_config_rel,
13896        lang_chart_json,
13897        scatter_chart_json: String::new(),
13898        semantic_chart_json: String::new(),
13899        submodule_chart_json: String::new(),
13900        has_submodule_data: !run.submodule_summaries.is_empty(),
13901        has_semantic_data: run
13902            .totals_by_language
13903            .iter()
13904            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
13905        csp_nonce: String::new(),
13906        confluence_configured: false,
13907        server_mode: false,
13908        report_header_footer: run
13909            .effective_configuration
13910            .reporting
13911            .report_header_footer
13912            .clone(),
13913        is_offline: true,
13914        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
13915        lsloc: run.summary_totals.lsloc,
13916        uloc: run.uloc,
13917        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
13918        duplicate_group_count: run.duplicate_groups.len(),
13919        has_cocomo: run.cocomo.is_some(),
13920        cocomo_effort_str: run
13921            .cocomo
13922            .as_ref()
13923            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
13924        cocomo_duration_str: run
13925            .cocomo
13926            .as_ref()
13927            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
13928        cocomo_staff_str: run
13929            .cocomo
13930            .as_ref()
13931            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
13932        cocomo_ksloc_str: run
13933            .cocomo
13934            .as_ref()
13935            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
13936        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
13937            || "Organic".to_string(),
13938            |c| {
13939                use sloc_core::CocomoMode;
13940                match c.mode {
13941                    CocomoMode::Organic => "Organic",
13942                    CocomoMode::SemiDetached => "Semi-detached",
13943                    CocomoMode::Embedded => "Embedded",
13944                }
13945                .to_string()
13946            },
13947        ),
13948        cocomo_mode_tooltip: run.cocomo.as_ref().map_or(String::new(), |c| {
13949            use sloc_core::CocomoMode;
13950            match c.mode {
13951                CocomoMode::Organic => {
13952                    "Organic: A small team working on a well-understood \
13953                    project in a familiar environment with minimal external constraints. \
13954                    Suited for internal tools, utilities, and projects with stable requirements. \
13955                    Effort = 2.4 \u{00D7} KSLOC^1.05."
13956                }
13957                CocomoMode::SemiDetached => {
13958                    "Semi-detached: A mixed team with varying experience \
13959                    tackling a project with moderate novelty and some rigid constraints. \
13960                    Typical for compilers, transaction systems, and batch processors. \
13961                    Effort = 3.0 \u{00D7} KSLOC^1.12."
13962                }
13963                CocomoMode::Embedded => {
13964                    "Embedded: Tight hardware, software, or operational \
13965                    constraints requiring significant innovation and deep integration work. \
13966                    Typical for real-time control systems and safety-critical software. \
13967                    Effort = 3.6 \u{00D7} KSLOC^1.20."
13968                }
13969            }
13970            .to_string()
13971        }),
13972        complexity_alert: 0,
13973    };
13974
13975    if let Ok(html) = template.render() {
13976        let index_path = run_dir.join("index.html");
13977        if let Err(e) = fs::write(&index_path, html) {
13978            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
13979        }
13980    }
13981}
13982
13983/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
13984/// then root (old flat layout), for backwards compatibility.
13985fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
13986    // New layout: json/scan-config_*.json
13987    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
13988        return Some(found);
13989    }
13990    // Old flat layout: scan-config.json or scan-config_*.json at root
13991    find_scan_config_in_dir_flat(dir)
13992}
13993
13994fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
13995    let exact = dir.join("scan-config.json");
13996    if exact.exists() {
13997        return Some(exact);
13998    }
13999    fs::read_dir(dir).ok().and_then(|entries| {
14000        entries
14001            .filter_map(std::result::Result::ok)
14002            .find(|e| {
14003                let name = e.file_name();
14004                let name = name.to_string_lossy();
14005                name.starts_with("scan-config") && name.ends_with(".json")
14006            })
14007            .map(|e| e.path())
14008    })
14009}
14010
14011// ── Config export / import ────────────────────────────────────────────────────
14012
14013/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
14014/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
14015#[derive(Deserialize)]
14016struct ExportPdfRequest {
14017    html: String,
14018    #[serde(default)]
14019    filename: Option<String>,
14020}
14021
14022async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
14023    let html_content = body.html;
14024    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
14025    if html_content.is_empty() {
14026        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
14027    }
14028    // Write HTML to a temp file, run headless Chrome PDF export, read result.
14029    let tmp_dir = std::env::temp_dir();
14030    let html_path = tmp_dir.join(format!(
14031        "sloc-export-{}.html",
14032        uuid::Uuid::new_v4().simple()
14033    ));
14034    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
14035    if let Err(e) = std::fs::write(&html_path, &html_content) {
14036        return (
14037            StatusCode::INTERNAL_SERVER_ERROR,
14038            format!("Failed to write temp HTML: {e}"),
14039        )
14040            .into_response();
14041    }
14042    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
14043    let _ = std::fs::remove_file(&html_path);
14044    if let Err(e) = pdf_result {
14045        let _ = std::fs::remove_file(&pdf_path);
14046        return (
14047            StatusCode::INTERNAL_SERVER_ERROR,
14048            format!("PDF generation failed: {e}"),
14049        )
14050            .into_response();
14051    }
14052    let pdf_bytes = match std::fs::read(&pdf_path) {
14053        Ok(b) => b,
14054        Err(e) => {
14055            let _ = std::fs::remove_file(&pdf_path);
14056            return (
14057                StatusCode::INTERNAL_SERVER_ERROR,
14058                format!("Failed to read PDF: {e}"),
14059            )
14060                .into_response();
14061        }
14062    };
14063    let _ = std::fs::remove_file(&pdf_path);
14064    let safe_name: String = filename
14065        .chars()
14066        .map(|c| {
14067            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
14068                c
14069            } else {
14070                '_'
14071            }
14072        })
14073        .collect();
14074    let disposition = format!("attachment; filename=\"{safe_name}\"");
14075    (
14076        [
14077            (header::CONTENT_TYPE, "application/pdf".to_string()),
14078            (header::CONTENT_DISPOSITION, disposition),
14079        ],
14080        pdf_bytes,
14081    )
14082        .into_response()
14083}
14084
14085async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
14086    let toml_str = match toml::to_string_pretty(&state.base_config) {
14087        Ok(s) => s,
14088        Err(e) => {
14089            return (
14090                StatusCode::INTERNAL_SERVER_ERROR,
14091                format!("serialization error: {e}"),
14092            )
14093                .into_response();
14094        }
14095    };
14096    (
14097        [
14098            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
14099            (
14100                header::CONTENT_DISPOSITION,
14101                "attachment; filename=\".oxide-sloc.toml\"",
14102            ),
14103        ],
14104        toml_str,
14105    )
14106        .into_response()
14107}
14108
14109#[derive(Serialize)]
14110struct OkResponse {
14111    ok: bool,
14112}
14113
14114#[derive(Serialize)]
14115struct SaveProfileResponse {
14116    ok: bool,
14117    id: String,
14118}
14119
14120#[derive(Serialize)]
14121struct ProfileListResponse {
14122    profiles: Vec<ScanProfile>,
14123}
14124
14125#[derive(Serialize)]
14126struct ImportConfigResponse {
14127    ok: bool,
14128    config: sloc_config::AppConfig,
14129}
14130
14131#[derive(Deserialize)]
14132struct ImportConfigBody {
14133    toml: String,
14134}
14135
14136async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
14137    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
14138        Ok(config) => {
14139            if let Err(e) = config.validate() {
14140                return error::unprocessable_entity(&e.to_string());
14141            }
14142            Json(ImportConfigResponse { ok: true, config }).into_response()
14143        }
14144        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
14145    }
14146}
14147
14148// ── Scan profiles API ─────────────────────────────────────────────────────────
14149
14150async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
14151    let store = state.scan_profiles.lock().await;
14152    Json(ProfileListResponse {
14153        profiles: store.profiles.clone(),
14154    })
14155}
14156
14157#[derive(Deserialize)]
14158struct SaveScanProfileBody {
14159    name: String,
14160    params: serde_json::Value,
14161}
14162
14163async fn api_save_scan_profile(
14164    State(state): State<AppState>,
14165    Json(body): Json<SaveScanProfileBody>,
14166) -> impl IntoResponse {
14167    if body.name.trim().is_empty() {
14168        return error::bad_request("name must not be empty");
14169    }
14170
14171    let id = uuid::Uuid::new_v4().to_string();
14172    let profile = ScanProfile {
14173        id: id.clone(),
14174        name: body.name.trim().to_string(),
14175        created_at: chrono::Utc::now().to_rfc3339(),
14176        params: body.params,
14177    };
14178
14179    let mut store = state.scan_profiles.lock().await;
14180    store.profiles.push(profile);
14181    if let Err(e) = store.save(&state.scan_profiles_path) {
14182        tracing::warn!("failed to persist scan profiles: {e}");
14183    }
14184    drop(store);
14185
14186    (
14187        StatusCode::CREATED,
14188        Json(SaveProfileResponse { ok: true, id }),
14189    )
14190        .into_response()
14191}
14192
14193async fn api_delete_scan_profile(
14194    State(state): State<AppState>,
14195    AxumPath(id): AxumPath<String>,
14196) -> impl IntoResponse {
14197    let mut store = state.scan_profiles.lock().await;
14198    let before = store.profiles.len();
14199    store.profiles.retain(|p| p.id != id);
14200    if store.profiles.len() == before {
14201        drop(store);
14202        return error::not_found("profile not found");
14203    }
14204    if let Err(e) = store.save(&state.scan_profiles_path) {
14205        tracing::warn!("failed to persist scan profiles: {e}");
14206    }
14207    drop(store);
14208    Json(OkResponse { ok: true }).into_response()
14209}
14210
14211fn resolve_output_root(raw: Option<&str>) -> PathBuf {
14212    let value = raw.unwrap_or("out/web").trim();
14213    let path = if value.is_empty() {
14214        PathBuf::from("out/web")
14215    } else {
14216        PathBuf::from(value)
14217    };
14218
14219    if path.is_absolute() {
14220        path
14221    } else {
14222        workspace_root().join(path)
14223    }
14224}
14225
14226/// Derive the directory that holds remote-repo clones from the output root.
14227fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
14228    std::env::var("SLOC_GIT_CLONES_DIR")
14229        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
14230}
14231
14232/// Build a deterministic filesystem path for a cloned remote repository.
14233/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
14234pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
14235    let safe: String = repo_url
14236        .chars()
14237        .map(|c| {
14238            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
14239                c
14240            } else {
14241                '_'
14242            }
14243        })
14244        .take(80)
14245        .collect();
14246    clones_dir.join(safe)
14247}
14248
14249/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
14250/// Runs synchronously — call from `tokio::task::spawn_blocking`.
14251pub(crate) fn scan_path_to_artifacts(
14252    scan_path: &Path,
14253    base_config: &AppConfig,
14254    label: &str,
14255) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
14256    let mut config = base_config.clone();
14257    config.discovery.root_paths = vec![scan_path.to_path_buf()];
14258    label.clone_into(&mut config.reporting.report_title);
14259    let run = analyze(&config, "git", None, None)?;
14260    let html = render_html(&run)?;
14261    let run_id = run.tool.run_id.clone();
14262    let project_label = sanitize_project_label(label);
14263    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
14264    let file_stem = {
14265        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
14266        if commit.is_empty() {
14267            project_label
14268        } else {
14269            format!("{project_label}_{commit}")
14270        }
14271    };
14272    let (artifacts, _pending_pdf) = persist_run_artifacts(
14273        &run,
14274        &html,
14275        &output_dir,
14276        label,
14277        &file_stem,
14278        RunResultContext::default(),
14279    )?;
14280    Ok((run_id, artifacts, run))
14281}
14282
14283/// Re-spawn background poll tasks for any polling schedules saved to disk.
14284async fn restart_poll_schedules(state: &AppState) {
14285    let store = state.schedules.lock().await;
14286    let poll_schedules: Vec<_> = store
14287        .schedules
14288        .iter()
14289        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
14290        .cloned()
14291        .collect();
14292    drop(store);
14293    for schedule in poll_schedules {
14294        let interval = schedule.interval_secs.unwrap_or(300);
14295        let st = state.clone();
14296        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
14297    }
14298}
14299
14300fn split_patterns(raw: Option<&str>) -> Vec<String> {
14301    raw.unwrap_or("")
14302        .lines()
14303        .flat_map(|line| line.split(','))
14304        .map(str::trim)
14305        .filter(|part| !part.is_empty())
14306        .map(ToOwned::to_owned)
14307        .collect()
14308}
14309
14310#[must_use]
14311pub fn build_sub_run(
14312    parent: &AnalysisRun,
14313    sub: &sloc_core::SubmoduleSummary,
14314    parent_path: &str,
14315) -> AnalysisRun {
14316    let sub_files: Vec<_> = parent
14317        .per_file_records
14318        .iter()
14319        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
14320        .cloned()
14321        .collect();
14322    let mut config = parent.effective_configuration.clone();
14323    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
14324
14325    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
14326    let mut functions = 0u64;
14327    let mut classes = 0u64;
14328    let mut variables = 0u64;
14329    let mut imports = 0u64;
14330    let mut test_count = 0u64;
14331    let mut test_assertion_count = 0u64;
14332    let mut test_suite_count = 0u64;
14333    let mut mixed_lines_separate = 0u64;
14334    let mut coverage_lines_found = 0u64;
14335    let mut coverage_lines_hit = 0u64;
14336    let mut coverage_functions_found = 0u64;
14337    let mut coverage_functions_hit = 0u64;
14338    let mut coverage_branches_found = 0u64;
14339    let mut coverage_branches_hit = 0u64;
14340    for r in &sub_files {
14341        functions += r.raw_line_categories.functions;
14342        classes += r.raw_line_categories.classes;
14343        variables += r.raw_line_categories.variables;
14344        imports += r.raw_line_categories.imports;
14345        test_count += r.raw_line_categories.test_count;
14346        test_assertion_count += r.raw_line_categories.test_assertion_count;
14347        test_suite_count += r.raw_line_categories.test_suite_count;
14348        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
14349        if let Some(cov) = &r.coverage {
14350            coverage_lines_found += u64::from(cov.lines_found);
14351            coverage_lines_hit += u64::from(cov.lines_hit);
14352            coverage_functions_found += u64::from(cov.functions_found);
14353            coverage_functions_hit += u64::from(cov.functions_hit);
14354            coverage_branches_found += u64::from(cov.branches_found);
14355            coverage_branches_hit += u64::from(cov.branches_hit);
14356        }
14357    }
14358
14359    AnalysisRun {
14360        tool: parent.tool.clone(),
14361        environment: parent.environment.clone(),
14362        effective_configuration: config,
14363        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
14364        summary_totals: SummaryTotals {
14365            files_considered: sub.files_analyzed,
14366            files_analyzed: sub.files_analyzed,
14367            files_skipped: 0,
14368            total_physical_lines: sub.total_physical_lines,
14369            code_lines: sub.code_lines,
14370            comment_lines: sub.comment_lines,
14371            blank_lines: sub.blank_lines,
14372            mixed_lines_separate,
14373            functions,
14374            classes,
14375            variables,
14376            imports,
14377            test_count,
14378            test_assertion_count,
14379            test_suite_count,
14380            coverage_lines_found,
14381            coverage_lines_hit,
14382            coverage_functions_found,
14383            coverage_functions_hit,
14384            coverage_branches_found,
14385            coverage_branches_hit,
14386            cyclomatic_complexity: 0,
14387            lsloc: None,
14388        },
14389        totals_by_language: sub.language_summaries.clone(),
14390        per_file_records: sub_files,
14391        skipped_file_records: vec![],
14392        warnings: vec![],
14393        submodule_summaries: vec![],
14394        git_commit_short: sub.git_commit_short.clone(),
14395        git_commit_long: sub.git_commit_long.clone(),
14396        git_branch: sub.git_branch.clone(),
14397        git_commit_author: sub.git_commit_author.clone(),
14398        git_commit_date: sub.git_commit_date.clone(),
14399        git_tags: None,
14400        git_nearest_tag: None,
14401        git_remote_url: sub.git_remote_url.clone(),
14402        style_summary: None,
14403        cocomo: None,
14404        uloc: 0,
14405        dryness_pct: None,
14406        duplicate_groups: vec![],
14407        duplicates_excluded: 0,
14408    }
14409}
14410
14411#[must_use]
14412pub fn sanitize_project_label(raw: &str) -> String {
14413    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
14414    // where `Path` treats '\' as a literal character, not a separator.
14415    let candidate = raw
14416        .split(['/', '\\'])
14417        .rfind(|s| !s.is_empty())
14418        .unwrap_or("project");
14419
14420    let mut value = String::with_capacity(candidate.len());
14421    for ch in candidate.chars() {
14422        if ch.is_ascii_alphanumeric() {
14423            value.push(ch.to_ascii_lowercase());
14424        } else {
14425            value.push('-');
14426        }
14427    }
14428
14429    let compact = value.trim_matches('-').to_string();
14430    if compact.is_empty() {
14431        "project".to_string()
14432    } else {
14433        compact
14434    }
14435}
14436
14437/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
14438/// comparisons with non-canonicalized stored paths work correctly.
14439fn strip_unc_prefix(path: PathBuf) -> PathBuf {
14440    let s = path.to_string_lossy();
14441    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14442        return PathBuf::from(format!(r"\\{rest}"));
14443    }
14444    if let Some(rest) = s.strip_prefix(r"\\?\") {
14445        return PathBuf::from(rest);
14446    }
14447    path
14448}
14449
14450/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
14451/// commit page URL for the most common hosting platforms.
14452fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14453    let base = if let Some(rest) = remote.strip_prefix("git@") {
14454        let (host, path) = rest.split_once(':')?;
14455        format!("https://{}/{}", host, path.trim_end_matches(".git"))
14456    } else if remote.starts_with("https://") || remote.starts_with("http://") {
14457        remote
14458            .trim_end_matches('/')
14459            .trim_end_matches(".git")
14460            .to_owned()
14461    } else {
14462        return None;
14463    };
14464    let base = base.trim_end_matches('/');
14465    // GitLab uses /-/commit/; everything else uses /commit/
14466    if base.contains("gitlab.com") || base.contains("gitlab.") {
14467        Some(format!("{base}/-/commit/{sha}"))
14468    } else if base.contains("bitbucket.org") {
14469        Some(format!("{base}/commits/{sha}"))
14470    } else {
14471        Some(format!("{base}/commit/{sha}"))
14472    }
14473}
14474
14475/// Convert a git remote URL (https or git@) + branch name into a browser-openable
14476/// branch page URL for the most common hosting platforms.
14477fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
14478    let base = if let Some(rest) = remote.strip_prefix("git@") {
14479        let (host, path) = rest.split_once(':')?;
14480        format!("https://{}/{}", host, path.trim_end_matches(".git"))
14481    } else if remote.starts_with("https://") || remote.starts_with("http://") {
14482        remote
14483            .trim_end_matches('/')
14484            .trim_end_matches(".git")
14485            .to_owned()
14486    } else {
14487        return None;
14488    };
14489    let base = base.trim_end_matches('/');
14490    if base.contains("gitlab.com") || base.contains("gitlab.") {
14491        Some(format!("{base}/-/tree/{branch}"))
14492    } else {
14493        Some(format!("{base}/tree/{branch}"))
14494    }
14495}
14496
14497fn display_path(path: &Path) -> String {
14498    let s = path.to_string_lossy();
14499    // Strip Windows extended-length prefix for display only; the underlying
14500    // PathBuf remains unchanged so file operations are unaffected.
14501    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
14502    // \\?\C:\path           →  C:\path          (local drive)
14503    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14504        return format!(r"\\{rest}");
14505    }
14506    if let Some(rest) = s.strip_prefix(r"\\?\") {
14507        return rest.to_owned();
14508    }
14509    s.into_owned()
14510}
14511
14512fn sanitize_path_str(s: &str) -> String {
14513    // Forward-slash variants of the Windows extended-length prefix that appear
14514    // when paths stored as plain strings have been processed through some path
14515    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
14516    if let Some(rest) = s.strip_prefix("//?/UNC/") {
14517        return format!("//{rest}");
14518    }
14519    if let Some(rest) = s.strip_prefix("//?/") {
14520        return rest.to_owned();
14521    }
14522    display_path(Path::new(s))
14523}
14524
14525fn workspace_root() -> PathBuf {
14526    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
14527    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
14528        let p = PathBuf::from(root);
14529        if p.is_dir() {
14530            return p;
14531        }
14532    }
14533
14534    // Current working directory — works for `cargo run` from the project root
14535    // and for scripts/run.sh which cds there first.
14536    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
14537}
14538
14539/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
14540fn make_git_label(repo: &str, ref_name: &str) -> String {
14541    if repo.is_empty() || ref_name.is_empty() {
14542        return String::new();
14543    }
14544    let base = repo
14545        .trim_end_matches('/')
14546        .trim_end_matches(".git")
14547        .rsplit('/')
14548        .next()
14549        .unwrap_or("repo");
14550    let ref_safe: String = ref_name
14551        .chars()
14552        .map(|c| {
14553            if c.is_alphanumeric() || c == '-' || c == '.' {
14554                c
14555            } else {
14556                '_'
14557            }
14558        })
14559        .collect();
14560    format!("{base}_at_{ref_safe}_sloc")
14561}
14562
14563/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
14564fn desktop_dir() -> PathBuf {
14565    if let Ok(profile) = std::env::var("USERPROFILE") {
14566        let p = PathBuf::from(profile).join("Desktop");
14567        if p.exists() {
14568            return p;
14569        }
14570    }
14571    if let Ok(home) = std::env::var("HOME") {
14572        let p = PathBuf::from(home).join("Desktop");
14573        if p.exists() {
14574            return p;
14575        }
14576    }
14577    workspace_root().join("out").join("web")
14578}
14579
14580fn resolve_input_path(raw: &str) -> PathBuf {
14581    let trimmed = raw.trim();
14582    if trimmed.is_empty() {
14583        return workspace_root().join("samples").join("basic");
14584    }
14585
14586    let candidate = PathBuf::from(trimmed);
14587    let resolved = if candidate.is_absolute() {
14588        candidate
14589    } else {
14590        let rooted = workspace_root().join(&candidate);
14591        if rooted.exists() {
14592            rooted
14593        } else {
14594            workspace_root().join(candidate)
14595        }
14596    };
14597
14598    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
14599    // strip that prefix so stored paths and the displayed "Project path" are clean.
14600    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
14601    PathBuf::from(display_path(&canonical))
14602}
14603
14604fn dir_size_bytes(path: &Path) -> u64 {
14605    let mut total = 0u64;
14606    if let Ok(rd) = fs::read_dir(path) {
14607        for entry in rd.filter_map(Result::ok) {
14608            let p = entry.path();
14609            if p.is_file() {
14610                if let Ok(meta) = p.metadata() {
14611                    total += meta.len();
14612                }
14613            } else if p.is_dir() {
14614                total += dir_size_bytes(&p);
14615            }
14616        }
14617    }
14618    total
14619}
14620
14621#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
14622fn format_dir_size(bytes: u64) -> String {
14623    if bytes >= 1_073_741_824 {
14624        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
14625    } else if bytes >= 1_048_576 {
14626        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
14627    } else if bytes >= 1_024 {
14628        format!("{:.0} KB", bytes as f64 / 1_024.0)
14629    } else {
14630        format!("{bytes} B")
14631    }
14632}
14633
14634fn render_submodule_chips(
14635    root: &Path,
14636    submodules: &[(String, std::path::PathBuf)],
14637    out: &mut String,
14638) {
14639    use std::fmt::Write as _;
14640    let count = submodules.len();
14641    out.push_str(r#"<div class="submodule-preview-strip">"#);
14642    write!(
14643        out,
14644        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>"#,
14645        if count == 1 { "" } else { "s" }
14646    )
14647    .ok();
14648    out.push_str(r#"<div class="submodule-preview-chips">"#);
14649    for (sub_name, sub_rel_path) in submodules {
14650        let sub_abs = root.join(sub_rel_path);
14651        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
14652        let mut sub_stats = PreviewStats::default();
14653        let mut sub_rows: Vec<PreviewRow> = Vec::new();
14654        let mut sub_langs: Vec<&'static str> = Vec::new();
14655        let mut sub_budget = PreviewBudget {
14656            shown: 0,
14657            max_entries: 2000,
14658            max_depth: 9,
14659        };
14660        let mut sub_next_id = 1usize;
14661        let _ = collect_preview_rows(
14662            &sub_abs,
14663            &sub_abs,
14664            0,
14665            None,
14666            &mut sub_next_id,
14667            &mut sub_budget,
14668            &mut sub_stats,
14669            &mut sub_rows,
14670            &mut sub_langs,
14671            &[],
14672            &[],
14673        );
14674        let stats_json = format!(
14675            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
14676            sub_stats.directories,
14677            sub_stats.files,
14678            sub_stats.supported,
14679            sub_stats.skipped,
14680            sub_stats.unsupported
14681        );
14682        write!(
14683            out,
14684            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>"#,
14685            escape_html(sub_name),
14686            escape_html(&sub_rel_path.to_string_lossy()),
14687            escape_html(&sub_size),
14688            escape_html(&stats_json),
14689            escape_html(sub_name),
14690            escape_html(&sub_size),
14691        )
14692        .ok();
14693    }
14694    out.push_str(
14695        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
14696    );
14697    out.push_str(r"</div>");
14698}
14699
14700fn render_language_pills_row(languages: &[&str], out: &mut String) {
14701    use std::fmt::Write as _;
14702    if languages.is_empty() {
14703        out.push_str(
14704            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
14705        );
14706        return;
14707    }
14708    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
14709    for language in languages {
14710        if let Some(icon) = language_icon_file(language) {
14711            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();
14712        } else if let Some(svg) = language_inline_svg(language) {
14713            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();
14714        } else {
14715            write!(
14716                out,
14717                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
14718                escape_html(&language.to_ascii_lowercase()),
14719                escape_html(language)
14720            )
14721            .ok();
14722        }
14723    }
14724}
14725
14726#[allow(clippy::too_many_lines)]
14727fn build_preview_html(
14728    root: &Path,
14729    include_patterns: &[String],
14730    exclude_patterns: &[String],
14731) -> Result<String> {
14732    if !root.exists() {
14733        return Ok(format!(
14734            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
14735            escape_html(&display_path(root))
14736        ));
14737    }
14738
14739    let _selected = display_path(root);
14740    let mut stats = PreviewStats::default();
14741    let mut rows = Vec::new();
14742    let mut languages = Vec::new();
14743    let mut budget = PreviewBudget {
14744        shown: 0,
14745        max_entries: 600,
14746        max_depth: 9,
14747    };
14748    let mut next_row_id = 1usize;
14749
14750    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
14751        || root.to_string_lossy().into_owned(),
14752        std::string::ToString::to_string,
14753    );
14754    let root_modified = root
14755        .metadata()
14756        .ok()
14757        .and_then(|meta| meta.modified().ok())
14758        .map_or_else(|| "-".to_string(), format_system_time);
14759
14760    rows.push(PreviewRow {
14761        row_id: 0,
14762        parent_row_id: None,
14763        depth: 0,
14764        name: format!("{root_name}/"),
14765        kind: PreviewKind::Dir,
14766        is_dir: true,
14767        language: None,
14768        modified: root_modified,
14769        type_label: "Directory".to_string(),
14770    });
14771    collect_preview_rows(
14772        root,
14773        root,
14774        0,
14775        Some(0),
14776        &mut next_row_id,
14777        &mut budget,
14778        &mut stats,
14779        &mut rows,
14780        &mut languages,
14781        include_patterns,
14782        exclude_patterns,
14783    )?;
14784
14785    let root_size = format_dir_size(dir_size_bytes(root));
14786
14787    let mut out = String::new();
14788    write!(
14789        out,
14790        r#"<div class="explorer-wrap" data-project-size="{}">"#,
14791        escape_html(&root_size)
14792    )
14793    .ok();
14794    out.push_str(r#"<div class="explorer-toolbar compact">"#);
14795    out.push_str(r#"<div class="explorer-title-group">"#);
14796    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
14797    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
14798    out.push_str(r"</div></div>");
14799
14800    out.push_str(r#"<div class="scope-stats">"#);
14801    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();
14802    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();
14803    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();
14804    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();
14805    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();
14806    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>"#);
14807    out.push_str(r"</div>");
14808
14809    let submodules = sloc_core::detect_submodules(root);
14810    if !submodules.is_empty() {
14811        render_submodule_chips(root, &submodules, &mut out);
14812    }
14813
14814    out.push_str(r#"<div class="scope-info-row">"#);
14815    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
14816    render_language_pills_row(&languages, &mut out);
14817    out.push_str(r"</div></div>");
14818    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>"#);
14819    out.push_str(r"</div>");
14820
14821    out.push_str(r#"<div class="file-explorer-shell">"#);
14822    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>"#);
14823    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>"#);
14824    out.push_str(r#"<div class="file-explorer-tree">"#);
14825    for row in rows {
14826        let status_label = row.kind.label();
14827        let lang_attr = row.language.unwrap_or("");
14828        let toggle_html = if row.is_dir {
14829            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
14830                .to_string()
14831        } else {
14832            r#"<span class="tree-bullet">•</span>"#.to_string()
14833        };
14834        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();
14835    }
14836    if budget.shown >= budget.max_entries {
14837        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>"#);
14838    }
14839    out.push_str(r"</div></div></div>");
14840
14841    Ok(out)
14842}
14843
14844#[derive(Default)]
14845struct PreviewStats {
14846    directories: usize,
14847    files: usize,
14848    supported: usize,
14849    skipped: usize,
14850    unsupported: usize,
14851}
14852
14853struct PreviewRow {
14854    row_id: usize,
14855    parent_row_id: Option<usize>,
14856    depth: usize,
14857    name: String,
14858    kind: PreviewKind,
14859    is_dir: bool,
14860    language: Option<&'static str>,
14861    modified: String,
14862    type_label: String,
14863}
14864
14865#[derive(Copy, Clone)]
14866enum PreviewKind {
14867    Dir,
14868    Supported,
14869    Skipped,
14870    Unsupported,
14871}
14872
14873impl PreviewKind {
14874    const fn filter_key(self) -> &'static str {
14875        match self {
14876            Self::Dir => "dir",
14877            Self::Supported => "supported",
14878            Self::Skipped => "skipped",
14879            Self::Unsupported => "unsupported",
14880        }
14881    }
14882
14883    const fn label(self) -> &'static str {
14884        match self {
14885            Self::Dir => "dir",
14886            Self::Supported => "supported",
14887            Self::Skipped => "skipped by policy",
14888            Self::Unsupported => "unsupported",
14889        }
14890    }
14891
14892    const fn badge_class(self) -> &'static str {
14893        match self {
14894            Self::Dir => "badge badge-dir",
14895            Self::Supported => "badge badge-scan",
14896            Self::Skipped => "badge badge-skip",
14897            Self::Unsupported => "badge badge-unsupported",
14898        }
14899    }
14900
14901    const fn node_class(self) -> &'static str {
14902        match self {
14903            Self::Dir => "tree-node-dir",
14904            Self::Supported => "tree-node-supported",
14905            Self::Skipped => "tree-node-skipped",
14906            Self::Unsupported => "tree-node-unsupported",
14907        }
14908    }
14909}
14910
14911struct PreviewBudget {
14912    shown: usize,
14913    max_entries: usize,
14914    max_depth: usize,
14915}
14916
14917/// Handle a single directory entry inside `collect_preview_rows`.
14918/// Returns `true` when the entry was handled (caller should `continue`).
14919#[allow(clippy::too_many_arguments)]
14920fn handle_preview_dir_entry(
14921    root: &Path,
14922    path: &Path,
14923    name: &str,
14924    modified: String,
14925    depth: usize,
14926    parent_row_id: Option<usize>,
14927    row_id: usize,
14928    next_row_id: &mut usize,
14929    budget: &mut PreviewBudget,
14930    stats: &mut PreviewStats,
14931    rows: &mut Vec<PreviewRow>,
14932    languages: &mut Vec<&'static str>,
14933    include_patterns: &[String],
14934    exclude_patterns: &[String],
14935) -> Result<()> {
14936    let relative = preview_relative_path(root, path);
14937    if should_skip_preview_directory(&relative, exclude_patterns) {
14938        return Ok(());
14939    }
14940    stats.directories += 1;
14941    rows.push(PreviewRow {
14942        row_id,
14943        parent_row_id,
14944        depth: depth + 1,
14945        name: format!("{name}/"),
14946        kind: PreviewKind::Dir,
14947        is_dir: true,
14948        language: None,
14949        modified,
14950        type_label: "Directory".to_string(),
14951    });
14952    budget.shown += 1;
14953    if !matches!(name, ".git" | "node_modules" | "target") {
14954        collect_preview_rows(
14955            root,
14956            path,
14957            depth + 1,
14958            Some(row_id),
14959            next_row_id,
14960            budget,
14961            stats,
14962            rows,
14963            languages,
14964            include_patterns,
14965            exclude_patterns,
14966        )?;
14967    }
14968    Ok(())
14969}
14970
14971/// Handle a single file entry inside `collect_preview_rows`.
14972#[allow(clippy::too_many_arguments)]
14973fn handle_preview_file_entry(
14974    root: &Path,
14975    path: &Path,
14976    name: &str,
14977    modified: String,
14978    depth: usize,
14979    parent_row_id: Option<usize>,
14980    row_id: usize,
14981    budget: &mut PreviewBudget,
14982    stats: &mut PreviewStats,
14983    rows: &mut Vec<PreviewRow>,
14984    languages: &mut Vec<&'static str>,
14985    include_patterns: &[String],
14986    exclude_patterns: &[String],
14987) {
14988    let relative = preview_relative_path(root, path);
14989    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
14990        return;
14991    }
14992    stats.files += 1;
14993    let kind = classify_preview_file(name);
14994    match kind {
14995        PreviewKind::Supported => stats.supported += 1,
14996        PreviewKind::Skipped => stats.skipped += 1,
14997        PreviewKind::Unsupported => stats.unsupported += 1,
14998        PreviewKind::Dir => {}
14999    }
15000    let language = detect_language_name(name);
15001    if let Some(lang) = language {
15002        if !languages.contains(&lang) {
15003            languages.push(lang);
15004        }
15005    }
15006    rows.push(PreviewRow {
15007        row_id,
15008        parent_row_id,
15009        depth: depth + 1,
15010        name: name.to_owned(),
15011        kind,
15012        is_dir: false,
15013        language,
15014        modified,
15015        type_label: preview_type_label(name, language, kind),
15016    });
15017    budget.shown += 1;
15018}
15019
15020#[allow(clippy::too_many_arguments)]
15021#[allow(clippy::too_many_lines)]
15022fn collect_preview_rows(
15023    root: &Path,
15024    dir: &Path,
15025    depth: usize,
15026    parent_row_id: Option<usize>,
15027    next_row_id: &mut usize,
15028    budget: &mut PreviewBudget,
15029    stats: &mut PreviewStats,
15030    rows: &mut Vec<PreviewRow>,
15031    languages: &mut Vec<&'static str>,
15032    include_patterns: &[String],
15033    exclude_patterns: &[String],
15034) -> Result<()> {
15035    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
15036        return Ok(());
15037    }
15038
15039    let mut entries = fs::read_dir(dir)
15040        .with_context(|| format!("failed to read directory {}", dir.display()))?
15041        .filter_map(std::result::Result::ok)
15042        .collect::<Vec<_>>();
15043    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
15044
15045    for entry in entries {
15046        if budget.shown >= budget.max_entries {
15047            break;
15048        }
15049
15050        let path = entry.path();
15051        let name = entry.file_name().to_string_lossy().into_owned();
15052        let Ok(metadata) = entry.metadata() else {
15053            continue;
15054        };
15055        let row_id = *next_row_id;
15056        *next_row_id += 1;
15057        let modified = metadata
15058            .modified()
15059            .ok()
15060            .map_or_else(|| "-".to_string(), format_system_time);
15061
15062        if metadata.is_dir() {
15063            handle_preview_dir_entry(
15064                root,
15065                &path,
15066                &name,
15067                modified,
15068                depth,
15069                parent_row_id,
15070                row_id,
15071                next_row_id,
15072                budget,
15073                stats,
15074                rows,
15075                languages,
15076                include_patterns,
15077                exclude_patterns,
15078            )?;
15079            continue;
15080        }
15081
15082        if metadata.is_file() {
15083            handle_preview_file_entry(
15084                root,
15085                &path,
15086                &name,
15087                modified,
15088                depth,
15089                parent_row_id,
15090                row_id,
15091                budget,
15092                stats,
15093                rows,
15094                languages,
15095                include_patterns,
15096                exclude_patterns,
15097            );
15098        }
15099    }
15100
15101    Ok(())
15102}
15103
15104fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
15105    if let Some(language) = language {
15106        return format!("{language} source");
15107    }
15108    let lower = name.to_ascii_lowercase();
15109    let ext = Path::new(&lower)
15110        .extension()
15111        .and_then(|e| e.to_str())
15112        .unwrap_or("");
15113    match kind {
15114        PreviewKind::Skipped => {
15115            if lower.ends_with(".min.js") {
15116                "Minified asset".to_string()
15117            } else if [
15118                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
15119            ]
15120            .contains(&ext)
15121            {
15122                "Binary or archive".to_string()
15123            } else {
15124                "Skipped file".to_string()
15125            }
15126        }
15127        PreviewKind::Unsupported => {
15128            if ext.is_empty() {
15129                "Unsupported file".to_string()
15130            } else {
15131                format!("{} file", ext.to_ascii_uppercase())
15132            }
15133        }
15134        PreviewKind::Supported => "Supported source".to_string(),
15135        PreviewKind::Dir => "Directory".to_string(),
15136    }
15137}
15138
15139fn format_system_time(time: SystemTime) -> String {
15140    #[allow(clippy::cast_possible_wrap)]
15141    let secs = match time.duration_since(UNIX_EPOCH) {
15142        Ok(duration) => duration.as_secs() as i64,
15143        Err(_) => return "-".to_string(),
15144    };
15145    let days = secs.div_euclid(86_400);
15146    let secs_of_day = secs.rem_euclid(86_400);
15147    let (year, month, day) = civil_from_days(days);
15148    let hour = secs_of_day / 3_600;
15149    let minute = (secs_of_day % 3_600) / 60;
15150    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
15151}
15152
15153#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
15154fn civil_from_days(days: i64) -> (i32, u32, u32) {
15155    let z = days + 719_468;
15156    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
15157    let doe = z - era * 146_097;
15158    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
15159    let y = yoe + era * 400;
15160    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
15161    let mp = (5 * doy + 2) / 153;
15162    let d = doy - (153 * mp + 2) / 5 + 1;
15163    let m = mp + if mp < 10 { 3 } else { -9 };
15164    let year = y + i64::from(m <= 2);
15165    (year as i32, m as u32, d as u32)
15166}
15167
15168// The input is already lowercased via `to_ascii_lowercase()` before calling
15169// `ends_with`, so the comparisons are inherently case-insensitive.
15170#[allow(clippy::case_sensitive_file_extension_comparisons)]
15171fn detect_language_name(name: &str) -> Option<&'static str> {
15172    let lower = name.to_ascii_lowercase();
15173    if lower.ends_with(".c") || lower.ends_with(".h") {
15174        Some("C")
15175    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
15176        .iter()
15177        .any(|s| lower.ends_with(s))
15178    {
15179        Some("C++")
15180    } else if lower.ends_with(".cs") {
15181        Some("C#")
15182    } else if lower.ends_with(".py") {
15183        Some("Python")
15184    } else if lower.ends_with(".sh") {
15185        Some("Shell")
15186    } else if [".ps1", ".psm1", ".psd1"]
15187        .iter()
15188        .any(|s| lower.ends_with(s))
15189    {
15190        Some("PowerShell")
15191    } else {
15192        None
15193    }
15194}
15195
15196fn language_icon_file(language: &str) -> Option<&'static str> {
15197    match language {
15198        "C" => Some("c.png"),
15199        "C++" => Some("cpp.png"),
15200        "C#" => Some("c-sharp.png"),
15201        "Python" => Some("python.png"),
15202        "Shell" => Some("shell.png"),
15203        "PowerShell" => Some("powershell.png"),
15204        "JavaScript" => Some("java-script.png"),
15205        "HTML" => Some("html-5.png"),
15206        "Java" => Some("java.png"),
15207        "Visual Basic" => Some("visual-basic.png"),
15208        "Assembly" => Some("asm.png"),
15209        "Go" => Some("go.png"),
15210        "R" => Some("r.png"),
15211        "XML" => Some("xml.png"),
15212        "Groovy" => Some("groovy.png"),
15213        "Dockerfile" => Some("docker.png"),
15214        "Makefile" => Some("makefile.svg"),
15215        "Perl" => Some("perl.svg"),
15216        _ => None,
15217    }
15218}
15219
15220// Inline SVG badges for languages that have no PNG icon in images/icons/.
15221// Using inline SVG keeps the web UI fully self-contained — no extra files
15222// needed on disk, no 404s on air-gapped deployments.
15223// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
15224fn language_inline_svg(language: &str) -> Option<&'static str> {
15225    match language {
15226        "Rust" => Some(
15227            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>"##,
15228        ),
15229        "TypeScript" => Some(
15230            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>"##,
15231        ),
15232        _ => None,
15233    }
15234}
15235
15236// The input is already lowercased via `to_ascii_lowercase()` before the
15237// `ends_with` calls, so these comparisons are inherently case-insensitive.
15238#[allow(clippy::case_sensitive_file_extension_comparisons)]
15239fn classify_preview_file(name: &str) -> PreviewKind {
15240    let lower = name.to_ascii_lowercase();
15241
15242    let scannable = [
15243        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
15244        ".psm1", ".psd1",
15245    ]
15246    .iter()
15247    .any(|suffix| lower.ends_with(suffix));
15248
15249    if scannable {
15250        PreviewKind::Supported
15251    } else if lower.ends_with(".min.js")
15252        || lower.ends_with(".lock")
15253        || lower.ends_with(".png")
15254        || lower.ends_with(".jpg")
15255        || lower.ends_with(".jpeg")
15256        || lower.ends_with(".gif")
15257        || lower.ends_with(".zip")
15258        || lower.ends_with(".pdf")
15259        || lower.ends_with(".pyc")
15260        || lower.ends_with(".xz")
15261        || lower.ends_with(".tar")
15262        || lower.ends_with(".gz")
15263    {
15264        PreviewKind::Skipped
15265    } else {
15266        PreviewKind::Unsupported
15267    }
15268}
15269
15270fn preview_relative_path(root: &Path, path: &Path) -> String {
15271    path.strip_prefix(root)
15272        .ok()
15273        .unwrap_or(path)
15274        .to_string_lossy()
15275        .replace('\\', "/")
15276        .trim_matches('/')
15277        .to_string()
15278}
15279
15280fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
15281    if relative.is_empty() {
15282        return false;
15283    }
15284
15285    exclude_patterns.iter().any(|pattern| {
15286        wildcard_match(pattern, relative)
15287            || wildcard_match(pattern, &format!("{relative}/"))
15288            || wildcard_match(pattern, &format!("{relative}/placeholder"))
15289    })
15290}
15291
15292fn should_include_preview_file(
15293    relative: &str,
15294    include_patterns: &[String],
15295    exclude_patterns: &[String],
15296) -> bool {
15297    if relative.is_empty() {
15298        return true;
15299    }
15300
15301    let included = include_patterns.is_empty()
15302        || include_patterns
15303            .iter()
15304            .any(|pattern| wildcard_match(pattern, relative));
15305    let excluded = exclude_patterns
15306        .iter()
15307        .any(|pattern| wildcard_match(pattern, relative));
15308
15309    included && !excluded
15310}
15311
15312fn wildcard_match(pattern: &str, candidate: &str) -> bool {
15313    let pattern = pattern.trim().replace('\\', "/");
15314    let candidate = candidate.trim().replace('\\', "/");
15315    let p = pattern.as_bytes();
15316    let c = candidate.as_bytes();
15317    let mut pi = 0usize;
15318    let mut ci = 0usize;
15319    let mut star: Option<usize> = None;
15320    let mut star_match = 0usize;
15321
15322    while ci < c.len() {
15323        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
15324            pi += 1;
15325            ci += 1;
15326        } else if pi < p.len() && p[pi] == b'*' {
15327            while pi < p.len() && p[pi] == b'*' {
15328                pi += 1;
15329            }
15330            star = Some(pi);
15331            star_match = ci;
15332        } else if let Some(star_pi) = star {
15333            star_match += 1;
15334            ci = star_match;
15335            pi = star_pi;
15336        } else {
15337            return false;
15338        }
15339    }
15340
15341    while pi < p.len() && p[pi] == b'*' {
15342        pi += 1;
15343    }
15344
15345    pi == p.len()
15346}
15347
15348fn escape_html(value: &str) -> String {
15349    value
15350        .replace('&', "&amp;")
15351        .replace('<', "&lt;")
15352        .replace('>', "&gt;")
15353        .replace('"', "&quot;")
15354        .replace('\'', "&#39;")
15355}
15356
15357#[derive(Clone)]
15358struct SubmoduleRow {
15359    name: String,
15360    relative_path: String,
15361    files_analyzed: u64,
15362    code_lines: u64,
15363    comment_lines: u64,
15364    blank_lines: u64,
15365    total_physical_lines: u64,
15366    html_url: Option<String>,
15367}
15368
15369#[derive(Template)]
15370#[template(
15371    source = r##"
15372<!doctype html>
15373<html lang="en">
15374<head>
15375  <meta charset="utf-8">
15376  <title>OxideSLOC | tmp-sloc</title>
15377  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15378  <style nonce="{{ csp_nonce }}">
15379    :root {
15380      --bg: #efe9e2;
15381      --surface: #fcfaf7;
15382      --surface-2: #f7f0e8;
15383      --surface-3: #efe3d5;
15384      --line: #dfcfbf;
15385      --line-strong: #cfb29c;
15386      --text: #2f241c;
15387      --muted: #6f6257;
15388      --muted-2: #917f71;
15389      --nav: #b85d33;
15390      --nav-2: #7a371b;
15391      --accent: #2563eb;
15392      --accent-2: #1d4ed8;
15393      --oxide: #b85d33;
15394      --oxide-2: #8f4220;
15395      --success-bg: #eaf9ee;
15396      --success-text: #1c8746;
15397      --warn-bg: #fff2d8;
15398      --warn-text: #926000;
15399      --danger-bg: #fdeaea;
15400      --danger-text: #b33b3b;
15401      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
15402      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
15403      --radius: 14px;
15404    }
15405
15406    body.dark-theme {
15407      --bg: #1b1511;
15408      --surface: #261c17;
15409      --surface-2: #2d221d;
15410      --surface-3: #372922;
15411      --line: #524238;
15412      --line-strong: #6c5649;
15413      --text: #f5ece6;
15414      --muted: #c7b7aa;
15415      --muted-2: #aa9485;
15416      --nav: #b85d33;
15417      --nav-2: #7a371b;
15418      --accent: #6f9bff;
15419      --accent-2: #4a78ee;
15420      --oxide: #d37a4c;
15421      --oxide-2: #b35428;
15422      --success-bg: #163927;
15423      --success-text: #8fe2a8;
15424      --warn-bg: #3c2d11;
15425      --warn-text: #f3cb75;
15426      --danger-bg: #3d1f1f;
15427      --danger-text: #ff9f9f;
15428      --shadow: 0 14px 28px rgba(0,0,0,0.28);
15429      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
15430    }
15431
15432    * { box-sizing: border-box; }
15433    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); }
15434    html { overflow-y: scroll; }
15435    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15436    .top-nav, .page, .loading { position: relative; z-index: 2; }
15437    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15438    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15439    .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); }
15440    .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; }
15441    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15442    .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)); }
15443    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15444    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15445    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15446    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15447    .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; }
15448    .nav-project-pill.visible { display:inline-flex; }
15449    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15450    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15451    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15452    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15453    @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; } }
15454    .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; }
15455    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
15456    .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; }
15457    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15458    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15459    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15460    .theme-toggle .icon-sun { display:none; }
15461    body.dark-theme .theme-toggle .icon-sun { display:block; }
15462    body.dark-theme .theme-toggle .icon-moon { display:none; }
15463    .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;}
15464    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15465    .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);}
15466    .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;}
15467    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15468    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15469    .settings-modal-body{padding:14px 16px 16px;}
15470    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15471    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15472    .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;}
15473    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15474    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15475    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15476    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15477    .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;}
15478    .tz-select:focus{border-color:var(--oxide);}
15479    .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; }
15480    .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;}
15481    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
15482    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
15483    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
15484    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
15485    .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; }
15486    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
15487    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
15488    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
15489    .wb-stats-header { padding: 10px 24px 0; }
15490    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
15491    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
15492    .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; }
15493    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15494    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15495    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15496    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
15497    .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; }
15498    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
15499    .ws-stat-analyzers { position: relative; }
15500    .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; }
15501    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
15502    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
15503    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
15504    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
15505    .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; }
15506    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
15507    .ws-divider { display: none; }
15508    .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%; }
15509    .ws-path-link:hover { color:var(--oxide); }
15510    body.dark-theme .ws-path-link { color:var(--oxide); }
15511    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
15512    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15513    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
15514    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15515    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
15516    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
15517    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
15518    .ws-mini-box-lg { flex:2 1 0; }
15519    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
15520    .ws-mini-box-br { flex:1.5 1 0; }
15521    .scope-legend-row { display:inline-flex; flex-direction:row; align-items:center; flex-wrap:wrap; gap:6px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:13px; flex-shrink:0; border-left:3px solid var(--line-strong); }
15522    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
15523    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
15524    #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; }
15525    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
15526    .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; }
15527    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
15528    .git-source-banner strong { font-weight:800; color:var(--text); }
15529    .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; }
15530    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
15531    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
15532    .git-source-banner a:hover { text-decoration:underline; }
15533    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
15534    .path-scope-sep { background:var(--line); margin:4px 14px; }
15535    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
15536    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
15537    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
15538    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
15539    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
15540    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
15541    .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; }
15542    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15543    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15544    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15545    .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; }
15546    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
15547    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
15548    [data-wb-tip] { cursor:help; }
15549    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
15550    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
15551    .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; }
15552    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
15553    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
15554    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
15555    .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; }
15556    .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); }
15557    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
15558    .side-info-card { padding: 18px; }
15559    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
15560    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
15561    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
15562    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
15563    .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); }
15564    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
15565    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15566    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
15567    .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; }
15568    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
15569    .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; }
15570    .side-stack::-webkit-scrollbar { display: none; }
15571    .step-nav { padding: 20px 16px; }
15572    .step-nav h3 { margin: 6px 4px 20px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
15573    .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; }
15574    .step-button:hover { background: var(--surface-2); }
15575    .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); }
15576    .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; }
15577    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15578    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
15579    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
15580    .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); }
15581    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
15582    .step-nav-sum-row:last-child { border-bottom:none; }
15583    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
15584    .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; }
15585    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
15586    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
15587    .quick-scan-section { padding: 10px 4px 14px; }
15588    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
15589    .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; }
15590    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
15591    .quick-scan-btn:active { transform:translateY(0); }
15592    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
15593    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
15594    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
15595    @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);} }
15596    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
15597    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
15598    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
15599    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
15600    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
15601    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
15602    .step-button.done .step-check { opacity:1; }
15603    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
15604    .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; }
15605    .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; }
15606    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
15607    .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; }
15608    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
15609    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
15610    .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; }
15611    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
15612    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15613    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
15614    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
15615    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
15616    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
15617    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
15618    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
15619    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
15620    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
15621    .card-body { padding: 22px; }
15622    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
15623    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
15624    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
15625    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
15626    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
15627    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
15628    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
15629    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
15630    .field { min-width:0; }
15631    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
15632    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; }
15633    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); }
15634    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
15635    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); }
15636    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
15637    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
15638    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
15639    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15640    .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; }
15641    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
15642    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
15643    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
15644    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
15645    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
15646    .input-group.compact { grid-template-columns: 1fr auto auto; }
15647    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
15648    .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)); }
15649    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
15650    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
15651    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
15652    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
15653    .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; }
15654    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
15655    .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; }
15656    .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); }
15657    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
15658    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
15659    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
15660    button.secondary { background: var(--surface); }
15661    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15662    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
15663    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
15664    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15665    .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); }
15666    .section + .wizard-actions { border-top: none; padding-top: 0; }
15667    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
15668    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15669    .field-help-grid.coupled-help { margin-top: 12px; }
15670    .field-help-grid.preset-grid { align-items: start; }
15671    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
15672    .preset-inline-row .field { margin: 0; }
15673    .preset-inline-row .explainer-card { margin: 0; }
15674    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
15675    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
15676    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
15677    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
15678    .preset-kv-row > :last-child { flex:1; min-width:0; }
15679    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
15680    .output-field-row .field { margin: 0; }
15681    .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; }
15682    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
15683    .step3-subtitle { margin-bottom: 10px; max-width: none; }
15684    .counting-intro { margin-bottom: 8px; max-width: none; }
15685    .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; }
15686    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
15687    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
15688    .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; }
15689    .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; }
15690    .section-spacer-top { margin-top: 28px; }
15691    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
15692    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
15693    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
15694    .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); }
15695    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
15696    .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; }
15697    .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; }
15698    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
15699    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15700    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
15701    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
15702    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
15703    .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; }
15704    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
15705    .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); }
15706    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; }
15707    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; }
15708    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
15709    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
15710    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
15711    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
15712    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
15713    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
15714    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
15715    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
15716    .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); }
15717    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
15718    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
15719    .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; }
15720    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
15721    .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; }
15722    .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; }
15723    .always-tracked-tip-body { flex:1; min-width:0; }
15724    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
15725    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
15726    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
15727    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
15728    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
15729    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
15730    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
15731    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
15732    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
15733    .advanced-rule-description strong { color: var(--text); }
15734    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
15735    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
15736    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
15737    .review-link:hover { text-decoration: underline; }
15738    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
15739    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15740    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
15741    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
15742    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
15743    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
15744    .review-card ul { padding-left: 18px; margin: 0; }
15745    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
15746    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
15747    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
15748    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
15749    .review-card { min-height: 0; }
15750    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
15751    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
15752    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
15753    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
15754    .lang-overflow-chip { position:relative; cursor:default; }
15755    .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; }
15756    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
15757    .git-inline-row { align-items:start; }
15758    .mixed-line-card { display:flex; flex-direction:column; }
15759    .preset-inline-row .toggle-card { justify-content: center; }
15760        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
15761    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
15762    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
15763    .explorer-title { font-size: 18px; font-weight: 850; }
15764    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
15765    .explorer-subtitle.wide { max-width: none; }
15766    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
15767    .better-spacing { align-items:flex-start; justify-content:flex-end; }
15768    .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; }
15769    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
15770    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
15771    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
15772    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
15773    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
15774    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
15775    .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; }
15776    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
15777    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
15778    .scope-stat-button.supported { background: var(--success-bg); }
15779    .scope-stat-button.skipped { background: var(--warn-bg); }
15780    .scope-stat-button.unsupported { background: var(--danger-bg); }
15781    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
15782    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
15783    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
15784    [data-tooltip] { position: relative; }
15785    [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); }
15786    [data-tooltip]:hover::after { display: block; }
15787    .scope-stat-button[data-tooltip] { cursor: pointer; }
15788    .badge[data-tooltip] { cursor: help; }
15789    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
15790    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
15791    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
15792    .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; }
15793    .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; }
15794    code { display:inline-block; margin-top:0; padding:2px 7px; }
15795    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15796    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
15797    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
15798    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
15799    .language-pill.muted-pill { color: var(--muted); }
15800    button.language-pill { appearance:none; cursor:pointer; }
15801    .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); }
15802    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
15803    .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; }
15804    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
15805    .file-explorer-search-row { margin-left: auto; }
15806    .explorer-filter-select { min-width: 170px; width: 170px; }
15807    .explorer-search { min-width: 300px; width: 300px; }
15808    .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); }
15809    .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; }
15810    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
15811    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
15812    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
15813    .file-explorer-tree { max-height: 640px; overflow:auto; }
15814    .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); }
15815    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
15816    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
15817    .tree-row.hidden-by-filter { display:none !important; }
15818    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
15819    .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; }
15820    .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; }
15821    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
15822    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
15823    .tree-node { display:inline-flex; align-items:center; min-width:0; }
15824    .tree-node-dir { color: var(--text); font-weight: 800; }
15825    .tree-node-supported { color: var(--success-text); }
15826    .tree-node-skipped { color: var(--warn-text); }
15827    .tree-node-unsupported { color: var(--danger-text); }
15828    .tree-node-more { color: var(--muted-2); font-style: italic; }
15829    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
15830    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
15831    .tree-status-cell { display:flex; justify-content:flex-start; }
15832    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
15833    .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; }
15834    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15835    .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; }
15836    @keyframes prevSpin { to { transform:rotate(360deg); } }
15837    .preview-loading-text { flex:1; min-width:0; }
15838    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
15839    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
15840    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
15841    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
15842    .cov-scan-idle { display:none; }
15843    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
15844    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
15845    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
15846    .cov-scan-title { font-weight:600; font-size:12.5px; }
15847    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
15848    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
15849    .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; }
15850    .cov-scan-use:hover { opacity:.75; }
15851    .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; }
15852    .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; }
15853    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
15854    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
15855    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
15856    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
15857    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
15858    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
15859    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
15860    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
15861    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
15862    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
15863    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
15864    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
15865    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
15866    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
15867    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
15868    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
15869    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
15870    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
15871    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
15872    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
15873    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
15874    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
15875    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
15876    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
15877    .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); }
15878    .loading.active { display:flex; }
15879    .loading-card { 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; }
15880    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
15881    .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent-2), var(--oxide,#d37a4c)); animation: pulseBar 1.6s ease-in-out infinite; }
15882    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
15883    .lc-badge { display:inline-flex;align-items:center;gap:10px;background:linear-gradient(135deg,rgba(211,122,76,0.16),rgba(184,93,51,0.08));border:1.5px solid rgba(211,122,76,0.44);border-radius:10px;padding:8px 18px 8px 13px;font-size:12px;font-weight:800;color:var(--oxide,#d37a4c);text-transform:uppercase;letter-spacing:.07em;margin-bottom:20px;box-shadow:0 2px 16px rgba(211,122,76,0.16); }
15884    .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
15885    .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
15886    .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
15887    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
15888    @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
15889    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
15890    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
15891    .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; }
15892    .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
15893    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
15894    .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
15895    .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
15896    .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; }
15897    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
15898    .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; }
15899    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
15900    .lc-step.done { color:var(--muted);opacity:0.55; }
15901    .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; }
15902    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
15903    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
15904    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
15905    .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; }
15906    .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; }
15907    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
15908    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
15909    .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; }
15910    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
15911    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
15912    .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; }
15913    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
15914    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
15915    .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; }
15916    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
15917    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
15918    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
15919    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
15920    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
15921    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
15922    .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; }
15923    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
15924    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
15925    .hidden { display:none !important; }
15926    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15927    .site-footer a{color:var(--muted);}
15928    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
15929    @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; } }
15930    .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;}
15931    @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));}}
15932    .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;}
15933    .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; }
15934    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
15935    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
15936    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
15937    .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; }
15938    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
15939    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
15940    .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .18s ease; z-index:300; }
15941    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
15942    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
15943    .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; }
15944    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
15945    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
15946    .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; }
15947    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
15948    .info-icon-btn:hover { color:var(--text); }
15949    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); }
15950    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
15951    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
15952    .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;}
15953    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15954    .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;}
15955    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15956    #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);}
15957    #offline-file-banner.show{display:flex;}
15958    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
15959    #offline-file-banner .ofb-text{flex:1;}
15960    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
15961    #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;}
15962    #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;}
15963    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
15964    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
15965    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
15966    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
15967    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
15968    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
15969    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
15970  </style>
15971</head>
15972<body id="page-top">
15973  <div id="offline-file-banner" role="alert">
15974    <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>
15975    <span class="ofb-text">
15976      Charts, images, and navigation require the oxide-sloc server.
15977      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
15978      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
15979      The metric tables below are fully readable without the server.
15980    </span>
15981    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
15982  </div>
15983  <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>
15984  <div class="background-watermarks" aria-hidden="true">
15985    <img src="/images/logo/logo-text.png" alt="" />
15986    <img src="/images/logo/logo-text.png" alt="" />
15987    <img src="/images/logo/logo-text.png" alt="" />
15988    <img src="/images/logo/logo-text.png" alt="" />
15989    <img src="/images/logo/logo-text.png" alt="" />
15990    <img src="/images/logo/logo-text.png" alt="" />
15991    <img src="/images/logo/logo-text.png" alt="" />
15992    <img src="/images/logo/logo-text.png" alt="" />
15993    <img src="/images/logo/logo-text.png" alt="" />
15994    <img src="/images/logo/logo-text.png" alt="" />
15995    <img src="/images/logo/logo-text.png" alt="" />
15996    <img src="/images/logo/logo-text.png" alt="" />
15997    <img src="/images/logo/logo-text.png" alt="" />
15998    <img src="/images/logo/logo-text.png" alt="" />
15999  </div>
16000  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16001  <div class="top-nav">
16002    <div class="top-nav-inner">
16003      <a class="brand" href="/">
16004        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
16005        <div class="brand-copy">
16006          <div class="brand-title">OxideSLOC</div>
16007          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
16008        </div>
16009      </a>
16010      <div class="nav-project-slot">
16011        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
16012          <span class="nav-project-label">Project</span>
16013          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
16014        </div>
16015      </div>
16016      <div class="nav-status">
16017        <a class="nav-pill" href="/">Home</a>
16018        <div class="nav-dropdown">
16019          <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>
16020          <div class="nav-dropdown-menu">
16021            <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>
16022          </div>
16023        </div>
16024        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16025        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16026        <div class="nav-dropdown">
16027          <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>
16028          <div class="nav-dropdown-menu">
16029            <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>
16030          </div>
16031        </div>
16032        <div class="server-status-wrap" id="server-status-wrap">
16033          <div class="nav-pill server-online-pill" id="server-status-pill">
16034            <span class="status-dot" id="status-dot"></span>
16035            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16036            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16037          </div>
16038          <div class="server-status-tip">
16039            {% if server_mode %}
16040            OxideSLOC is running in server mode — accessible on your LAN.
16041            {% else %}
16042            OxideSLOC is running locally — only accessible from this machine.
16043            {% endif %}
16044            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16045          </div>
16046        </div>
16047        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16048          <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>
16049        </button>
16050        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16051          <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>
16052          <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>
16053        </button>
16054      </div>
16055    </div>
16056  </div>
16057
16058  <div class="loading" id="loading">
16059    <div class="loading-card">
16060      <div class="lc-badge" id="lc-badge"><span class="lc-dot-wrap"><span class="lc-dot"></span><span class="lc-dot-ring"></span></span>Analysis running</div>
16061      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
16062      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
16063      <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>
16064      <div class="lc-steps" id="lc-steps">
16065        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
16066        <div class="lc-step-arrow">›</div>
16067        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
16068        <div class="lc-step-arrow">›</div>
16069        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
16070        <div class="lc-step-arrow">›</div>
16071        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
16072      </div>
16073      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
16074      <div class="lc-metrics" id="lc-metrics">
16075        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
16076        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
16077        <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>
16078        <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>
16079      </div>
16080      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
16081      <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>
16082      <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>
16083      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
16084      <div class="lc-actions hidden" id="lc-actions">
16085        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
16086        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
16087      </div>
16088      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
16089        <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>
16090        Cancel scan
16091      </button>
16092    </div>
16093  </div>
16094
16095  <div class="page">
16096    <div class="workbench-strip">
16097      <div class="workbench-box wb-stats">
16098        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
16099          <span class="wb-stats-title">Analysis session</span>
16100        </div>
16101        <div class="ws-left">
16102          <div class="ws-stat ws-stat-analyzers">
16103            <span class="ws-label">Analyzers</span>
16104            <span class="ws-value">
16105              <span class="ws-badge">60 languages</span>
16106            </span>
16107            <div class="ws-lang-tooltip">
16108              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
16109              <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>
16110              <div class="ws-lang-grid">
16111                <span class="ws-lang-item">Assembly</span>
16112                <span class="ws-lang-item">C</span>
16113                <span class="ws-lang-item">C++</span>
16114                <span class="ws-lang-item">C#</span>
16115                <span class="ws-lang-item">Clojure</span>
16116                <span class="ws-lang-item">CSS</span>
16117                <span class="ws-lang-item">Dart</span>
16118                <span class="ws-lang-item">Dockerfile</span>
16119                <span class="ws-lang-item">Elixir</span>
16120                <span class="ws-lang-item">Erlang</span>
16121                <span class="ws-lang-item">F#</span>
16122                <span class="ws-lang-item">Go</span>
16123                <span class="ws-lang-item">Groovy</span>
16124                <span class="ws-lang-item">Haskell</span>
16125                <span class="ws-lang-item">HTML</span>
16126                <span class="ws-lang-item">Java</span>
16127                <span class="ws-lang-item">JavaScript</span>
16128                <span class="ws-lang-item">Julia</span>
16129                <span class="ws-lang-item">Kotlin</span>
16130                <span class="ws-lang-item">Lua</span>
16131                <span class="ws-lang-item">Makefile</span>
16132                <span class="ws-lang-item">Nim</span>
16133                <span class="ws-lang-item">Obj-C</span>
16134                <span class="ws-lang-item">OCaml</span>
16135                <span class="ws-lang-item">Perl</span>
16136                <span class="ws-lang-item">PHP</span>
16137                <span class="ws-lang-item">PowerShell</span>
16138                <span class="ws-lang-item">Python</span>
16139                <span class="ws-lang-item">R</span>
16140                <span class="ws-lang-item">Ruby</span>
16141                <span class="ws-lang-item">Rust</span>
16142                <span class="ws-lang-item">Scala</span>
16143                <span class="ws-lang-item">SCSS</span>
16144                <span class="ws-lang-item">Shell</span>
16145                <span class="ws-lang-item">SQL</span>
16146                <span class="ws-lang-item">Svelte</span>
16147                <span class="ws-lang-item">Swift</span>
16148                <span class="ws-lang-item">TypeScript</span>
16149                <span class="ws-lang-item">Vue</span>
16150                <span class="ws-lang-item">XML</span>
16151                <span class="ws-lang-item">Zig</span>
16152                <span class="ws-lang-item">Solidity</span>
16153                <span class="ws-lang-item">Protobuf</span>
16154                <span class="ws-lang-item">HCL</span>
16155                <span class="ws-lang-item">GraphQL</span>
16156                <span class="ws-lang-item">Ada</span>
16157                <span class="ws-lang-item">VHDL</span>
16158                <span class="ws-lang-item">Verilog</span>
16159                <span class="ws-lang-item">Tcl</span>
16160                <span class="ws-lang-item">Pascal</span>
16161                <span class="ws-lang-item">Visual Basic</span>
16162                <span class="ws-lang-item">Lisp</span>
16163                <span class="ws-lang-item">Fortran</span>
16164                <span class="ws-lang-item">Nix</span>
16165                <span class="ws-lang-item">Crystal</span>
16166                <span class="ws-lang-item">D</span>
16167                <span class="ws-lang-item">GLSL</span>
16168                <span class="ws-lang-item">CMake</span>
16169                <span class="ws-lang-item">Elm</span>
16170                <span class="ws-lang-item">Awk</span>
16171              </div>
16172            </div>
16173          </div>
16174          <div class="ws-divider"></div>
16175          <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>
16176          <div class="ws-divider"></div>
16177          <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts — JSON, HTML, and PDF reports — are written after each completed scan.">
16178            <span class="ws-label">Output</span>
16179            <span class="ws-value">
16180              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
16181                <span id="ws-output-root">project/sloc</span>
16182              </button>
16183            </span>
16184          </div>
16185        </div>
16186      </div>
16187      <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.">
16188        <div class="ws-history-label">Scan history</div>
16189        <div class="ws-history-inner">
16190          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
16191            <div class="ws-mini-label">Scans</div>
16192            <div class="ws-mini-value" id="ws-scan-count">—</div>
16193          </div>
16194          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
16195            <div class="ws-mini-label">Last Scan</div>
16196            <div class="ws-mini-value" id="ws-last-scan">—</div>
16197          </div>
16198          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
16199            <div class="ws-mini-label">Branch</div>
16200            <div class="ws-mini-value" id="ws-branch">—</div>
16201          </div>
16202        </div>
16203      </div>
16204    </div>
16205
16206    <div class="layout">
16207      <aside class="side-stack">
16208        <section class="step-nav">
16209        <h3>Guided scan setup</h3>
16210        <div class="sidebar-scroll-divider"></div>
16211        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
16212          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
16213          Top of page
16214        </a>
16215        <div class="sidebar-scroll-divider"></div>
16216        <button type="button" class="step-button active" 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>
16217        <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>
16218        <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>
16219        <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>
16220
16221        <div class="step-steps-divider"></div>
16222
16223        <div class="step-nav-info" id="step-nav-info">
16224          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
16225          <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>
16226        </div>
16227
16228        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
16229          <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>
16230          <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>
16231          <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>
16232        </div>
16233
16234        <div class="quick-scan-divider"></div>
16235        <div class="quick-scan-section">
16236          <div class="quick-scan-label">No customization needed?</div>
16237          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
16238            <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>
16239            Quick Scan
16240          </button>
16241          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
16242        </div>
16243
16244        <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>
16245        <div class="sidebar-scroll-divider"></div>
16246        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
16247          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
16248          Skip to bottom
16249        </a>
16250        </section>
16251
16252      </aside>
16253
16254      <section class="card">
16255        <div class="card-header">
16256          <div class="card-title-row">
16257            <div>
16258              <h1 class="card-title">Guided scan configuration</h1>
16259              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
16260            </div>
16261            <div class="wizard-progress" aria-label="Scan setup progress">
16262              <div class="wizard-progress-top">
16263                <span class="wizard-progress-label">Setup progress</span>
16264                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
16265              </div>
16266              <div class="wizard-progress-track">
16267                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
16268              </div>
16269            </div>
16270          </div>
16271        </div>
16272        <div class="card-body">
16273          <form method="post" action="/analyze" id="analyze-form">
16274            <div class="wizard-step active" data-step="1">
16275              <div class="section">
16276                <div class="section-kicker">Step 1</div>
16277                <h2>Select project and preview scope</h2>
16278                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
16279                <div class="field">
16280                  <label for="path">Project path</label>
16281                  {% if !git_repo.is_empty() %}
16282                  <div class="git-source-banner">
16283                    <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>
16284                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
16285                    <a href="/git-browser">← Back to Git Browser</a>
16286                  </div>
16287                  {% endif %}
16288                  <div class="path-scope-grid">
16289                      {% if !git_repo.is_empty() %}
16290                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
16291                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
16292                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
16293                      {% else %}
16294                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
16295                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
16296                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
16297                      {% endif %}
16298                    <div class="path-scope-sep"></div>
16299                    <div class="scope-legend-row">
16300                      <span class="scope-legend-label">Scope legend:</span>
16301                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
16302                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
16303                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
16304                    </div>
16305                  </div>
16306                  {% if git_repo.is_empty() %}
16307                  {% if server_mode %}
16308                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
16309                    ℹ️ Files are compressed and streamed — no fixed size limit.
16310                  </div>
16311                  {% endif %}
16312                  <div class="path-info-row">
16313                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
16314                      <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>
16315                      <span id="project-size-text">Project size: —</span>
16316                    </button>
16317                  </div>
16318                  {% else %}
16319                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
16320                  {% endif %}
16321                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
16322                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
16323                </div>
16324
16325                <div class="scope-preview-divider" aria-hidden="true"></div>
16326
16327                <div id="preview-panel">
16328                  <div class="preview-error">Loading preview...</div>
16329                </div>
16330              </div>
16331
16332              <div class="section" style="margin-top:14px;">
16333                <div class="preset-inline-row git-inline-row">
16334                  <div class="toggle-card" style="margin:0;">
16335                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
16336                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
16337                    <label class="checkbox">
16338                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
16339                      <div>
16340                        <span>Detect and separate git submodules</span>
16341                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
16342                      </div>
16343                    </label>
16344                  </div>
16345                  <div class="explainer-card prominent" style="margin:0;">
16346                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16347                    <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>
16348                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
16349    path = libs/core
16350    url  = https://github.com/org/core.git
16351
16352[submodule "libs/ui"]
16353    path = libs/ui
16354    url  = https://github.com/org/ui.git</div>
16355                  </div>
16356                </div>
16357              </div>
16358
16359              <div class="section">
16360                <div class="field-grid">
16361                  <div class="field">
16362                    <div class="glob-label-row">
16363                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
16364                      <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>
16365                    </div>
16366                    <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>
16367                    <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>
16368                  </div>
16369                  <div class="field">
16370                    <div class="glob-label-row">
16371                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
16372                    </div>
16373                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
16374                    <div id="quick-exclude-chips" class="quick-excl-row">
16375                      <span class="quick-excl-label">Quick add:</span>
16376                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
16377                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
16378                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
16379                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
16380                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
16381                      <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>
16382                    </div>
16383                    <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>
16384                  </div>
16385                </div>
16386                <div class="glob-guidance-grid">
16387                  <div class="glob-guidance-card">
16388                    <strong>How to read them</strong>
16389                    <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>
16390                  </div>
16391                  <div class="glob-guidance-card">
16392                    <strong>Common include examples</strong>
16393                    <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>
16394                  </div>
16395                  <div class="glob-guidance-card">
16396                    <strong>Common exclude examples</strong>
16397                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
16398                  </div>
16399                </div>
16400              </div>
16401
16402              <div class="section" style="margin-top:14px;">
16403                <div class="preset-inline-row git-inline-row">
16404                  <div class="toggle-card" style="margin:0;">
16405                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
16406                    <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>
16407                    <div class="field" style="margin:0;">
16408                      <div class="input-group compact">
16409                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
16410                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
16411                      </div>
16412                      <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>
16413                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
16414                    </div>
16415                  </div>
16416                  <div class="explainer-card prominent" style="margin:0;">
16417                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16418                    <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>
16419                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
16420lcov --capture --directory . --output-file coverage/lcov.info
16421
16422# C / C++ — llvm-cov (LCOV)
16423llvm-profdata merge -sparse default.profraw -o default.profdata
16424llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
16425
16426# C# — coverlet (Cobertura XML)
16427dotnet test --collect:"XPlat Code Coverage"
16428
16429# Python — pytest-cov (Cobertura XML)
16430pytest --cov --cov-report=xml
16431
16432# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
16433./gradlew jacocoTestReport</div>
16434                  </div>
16435                </div>
16436              </div>
16437
16438              <div class="wizard-actions">
16439                <div class="left"></div>
16440                <div class="right">
16441                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
16442                </div>
16443              </div>
16444            </div>
16445
16446            <div class="wizard-step" data-step="2">
16447              <div class="section">
16448                <div class="section-kicker">Step 2</div>
16449                <h2>Choose counting behavior</h2>
16450                <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>
16451<div class="subsection-bar">Primary line classification</div>
16452                <div class="preset-kv-row">
16453                  <div class="toggle-card mixed-line-card" style="margin:0;">
16454                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
16455                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
16456                    <select id="mixed_line_policy" name="mixed_line_policy">
16457                      <option value="code_only">Code only</option>
16458                      <option value="code_and_comment">Code and comment</option>
16459                      <option value="comment_only">Comment only</option>
16460                      <option value="separate_mixed_category">Separate mixed category</option>
16461                    </select>
16462                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
16463                  </div>
16464                  <div class="explainer-card prominent" style="margin:0;">
16465                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
16466                    <div class="explainer-body" id="mixed-policy-description"></div>
16467                    <div class="code-sample" id="mixed-policy-example"></div>
16468                  </div>
16469                </div>
16470              </div>
16471
16472              <div class="subsection-bar">Additional scan rules</div>
16473              <div class="scan-rules-grid">
16474                <div class="preset-inline-row">
16475                  <div class="toggle-card" style="margin:0;">
16476                    <div class="field-help-title">Generated files</div>
16477                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
16478                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16479                  </div>
16480                  <div class="explainer-card prominent" style="margin:0;">
16481                    <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>
16482                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
16483# Files matching codegen patterns are excluded:
16484#   *.generated.cs  *.pb.go  *.g.dart</div>
16485                  </div>
16486                </div>
16487                <div class="preset-inline-row">
16488                  <div class="toggle-card" style="margin:0;">
16489                    <div class="field-help-title">Minified files</div>
16490                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
16491                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16492                  </div>
16493                  <div class="explainer-card prominent" style="margin:0;">
16494                    <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>
16495                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
16496# Heuristic: very long lines + low whitespace ratio
16497#   jquery.min.js  bundle.min.css  → skipped</div>
16498                  </div>
16499                </div>
16500                <div class="preset-inline-row">
16501                  <div class="toggle-card" style="margin:0;">
16502                    <div class="field-help-title">Vendor directories</div>
16503                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
16504                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16505                  </div>
16506                  <div class="explainer-card prominent" style="margin:0;">
16507                    <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>
16508                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
16509# Directories named vendor/ node_modules/ third_party/
16510#   → entire subtree is excluded from totals</div>
16511                  </div>
16512                </div>
16513                <div class="preset-inline-row">
16514                  <div class="toggle-card" style="margin:0;">
16515                    <div class="field-help-title">Lockfiles and manifests</div>
16516                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
16517                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
16518                  </div>
16519                  <div class="explainer-card prominent" style="margin:0;">
16520                    <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>
16521                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
16522# Files like package-lock.json  Cargo.lock  yarn.lock
16523#   → skipped unless this is enabled</div>
16524                  </div>
16525                </div>
16526                <div class="preset-inline-row">
16527                  <div class="toggle-card" style="margin:0;">
16528                    <div class="field-help-title">Binary handling</div>
16529                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
16530                    <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>
16531                  </div>
16532                  <div class="explainer-card prominent" style="margin:0;">
16533                    <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>
16534                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
16535# Detected via long lines + low whitespace heuristic
16536#   .png  .exe  .so  → skipped silently</div>
16537                  </div>
16538                </div>
16539                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
16540                  <div class="toggle-card" style="margin:0;">
16541                    <div class="field-help-title">Python docstrings</div>
16542                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
16543                    <label class="checkbox">
16544                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
16545                      <span>Count as comment-style lines</span>
16546                    </label>
16547                  </div>
16548                  <div class="explainer-card prominent" style="margin:0;">
16549                    <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>
16550                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
16551                  </div>
16552                </div>
16553              </div>
16554              <div class="subsection-bar">IEEE 1045-1992 counting</div>
16555              <div class="scan-rules-grid">
16556                <div class="preset-inline-row">
16557                  <div class="toggle-card" style="margin:0;">
16558                    <div class="field-help-title">Continuation lines</div>
16559                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
16560                    <select name="continuation_line_policy" id="continuation_line_policy">
16561                      <option value="each_physical_line" selected>Each physical line (default)</option>
16562                      <option value="collapse_to_logical">Collapse to logical line</option>
16563                    </select>
16564                  </div>
16565                  <div class="explainer-card prominent" style="margin:0;">
16566                    <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>
16567                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
16568    ((a) &gt; (b) ? (a) : (b))
16569# each_physical_line → 2 SLOC
16570# collapse_to_logical → 1 SLOC</div>
16571                  </div>
16572                </div>
16573                <div class="preset-inline-row">
16574                  <div class="toggle-card" style="margin:0;">
16575                    <div class="field-help-title">Block-comment blanks</div>
16576                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
16577                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
16578                      <option value="count_as_comment" selected>Count as comment (default)</option>
16579                      <option value="count_as_blank">Count as blank</option>
16580                    </select>
16581                  </div>
16582                  <div class="explainer-card prominent" style="margin:0;">
16583                    <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>
16584                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
16585 * Summary line
16586 *              ← blank inside block comment
16587 * Detail line
16588 */
16589# count_as_comment → blank counts toward comments
16590# count_as_blank   → blank counts toward blanks</div>
16591                  </div>
16592                </div>
16593                <div class="preset-inline-row">
16594                  <div class="toggle-card" style="margin:0;">
16595                    <div class="field-help-title">Compiler directives</div>
16596                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
16597                    <select name="count_compiler_directives" id="count_compiler_directives">
16598                      <option value="enabled" selected>Include in code SLOC (default)</option>
16599                      <option value="disabled">Exclude from code SLOC</option>
16600                    </select>
16601                  </div>
16602                  <div class="explainer-card prominent" style="margin:0;">
16603                    <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>
16604                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
16605#define BUF 256     ← compiler directive
16606int main() { … }   ← code
16607# enabled  → 3 code SLOC
16608# disabled → 1 code SLOC + 2 directive lines</div>
16609                  </div>
16610                </div>
16611              </div>
16612
16613              <div class="subsection-bar">Code Style Analysis</div>
16614              <div class="scan-rules-grid">
16615                <div class="preset-inline-row">
16616                  <div class="toggle-card" style="margin:0;">
16617                    <div class="field-help-title">Style analysis</div>
16618                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
16619                    <select name="style_analysis_enabled" id="style_analysis_enabled">
16620                      <option value="enabled" selected>Enabled (default)</option>
16621                      <option value="disabled">Disabled — skip style scoring</option>
16622                    </select>
16623                  </div>
16624                  <div class="explainer-card prominent" style="margin:0;">
16625                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
16626                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
16627# style_analysis_enabled = false  (skip, faster scan)
16628# Disabling removes the Code Style section from the report.</div>
16629                  </div>
16630                </div>
16631                <div class="preset-inline-row">
16632                  <div class="toggle-card" style="margin:0;">
16633                    <div class="field-help-title">Column-width threshold</div>
16634                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
16635                    <select name="style_col_threshold" id="style_col_threshold">
16636                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
16637                      <option value="100">100 columns (Uber Go, Google Java)</option>
16638                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
16639                    </select>
16640                  </div>
16641                  <div class="explainer-card prominent" style="margin:0;">
16642                    <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>
16643                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
16644# style_col_threshold = 100 (Uber Go, Google Java)
16645# style_col_threshold = 120 (Uber Go max, Kotlin)
16646# Files where &lt;= 5% of lines exceed the limit
16647# are counted as "N-col compliant" in the report.</div>
16648                  </div>
16649                </div>
16650                <div class="preset-inline-row">
16651                  <div class="toggle-card" style="margin:0;">
16652                    <div class="field-help-title">Score alert threshold</div>
16653                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
16654                    <select name="style_score_threshold" id="style_score_threshold">
16655                      <option value="0" selected>Off — no threshold (default)</option>
16656                      <option value="40">40% — flag poorly styled files</option>
16657                      <option value="50">50% — flag below-average files</option>
16658                      <option value="60">60% — flag below-good files</option>
16659                      <option value="70">70% — flag below-strong files</option>
16660                    </select>
16661                  </div>
16662                  <div class="explainer-card prominent" style="margin:0;">
16663                    <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>
16664                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
16665# style_score_threshold = 50  (flag files &lt; 50%)
16666# Low-scoring files get a red left-border in the
16667# per-file style breakdown table.</div>
16668                  </div>
16669                </div>
16670              </div>
16671
16672              <div class="always-tracked-tip">
16673                <div class="always-tracked-tip-icon">ℹ</div>
16674                <div class="always-tracked-tip-body">
16675                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
16676                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
16677                  <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>
16678                </div>
16679              </div>
16680
16681              <div class="subsection-bar">Advanced Metrics</div>
16682              <div class="scan-rules-grid">
16683                <div class="preset-inline-row">
16684                  <div class="toggle-card" style="margin:0;">
16685                    <div class="field-help-title">COCOMO mode</div>
16686                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
16687                    <select name="cocomo_mode" id="cocomo_mode">
16688                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
16689                      <option value="semi_detached">Semi-detached — mixed constraints</option>
16690                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
16691                    </select>
16692                  </div>
16693                  <div class="explainer-card prominent" style="margin:0;">
16694                    <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>
16695                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
16696# Semi-detached: Effort = 3.0 × KSLOC^1.12
16697# Embedded:     Effort = 3.6 × KSLOC^1.20
16698# All modes: Schedule = 2.5 × Effort^d</div>
16699                  </div>
16700                </div>
16701                <div class="preset-inline-row">
16702                  <div class="toggle-card" style="margin:0;">
16703                    <div class="field-help-title">Complexity alert</div>
16704                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
16705                    <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;" />
16706                  </div>
16707                  <div class="explainer-card prominent" style="margin:0;">
16708                    <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>
16709                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
16710# 50  = flag any file with &gt; 50 branch points
16711# 100 = flag any file with &gt; 100 branch points
16712# Files above the threshold are highlighted
16713# in the result page metric strip.</div>
16714                  </div>
16715                </div>
16716                <div class="preset-inline-row">
16717                  <div class="toggle-card" style="margin:0;">
16718                    <div class="field-help-title">Duplicate handling</div>
16719                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
16720                    <select name="exclude_duplicates" id="exclude_duplicates">
16721                      <option value="disabled" selected>Detect and report only (default)</option>
16722                      <option value="enabled">Detect and exclude from SLOC totals</option>
16723                    </select>
16724                  </div>
16725                  <div class="explainer-card prominent" style="margin:0;">
16726                    <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>
16727                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
16728# detect only   → all 3 counted in SLOC
16729# exclude dupes → 1 counted, 2 excluded
16730# Duplicate groups chip always shows the count.</div>
16731                  </div>
16732                </div>
16733                <div class="always-tracked-tip" style="margin:8px 0 0;">
16734                  <div class="always-tracked-tip-icon">ℹ</div>
16735                  <div class="always-tracked-tip-body">
16736                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
16737                    <div class="always-tracked-metrics-row">
16738                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
16739                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
16740                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
16741                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
16742                    </div>
16743                    <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>
16744                  </div>
16745                </div>
16746              </div>
16747
16748              <div class="wizard-actions">
16749                <div class="left">
16750                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
16751                </div>
16752                <div class="right">
16753                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
16754                </div>
16755              </div>
16756            </div>
16757
16758            <div class="wizard-step" data-step="3">
16759              <div class="section">
16760                <div class="section-kicker">Step 3</div>
16761                <h2>Output and report identity</h2>
16762                <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>
16763                <div class="preset-kv-row">
16764                  <div class="toggle-card" style="margin:0;">
16765                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
16766                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
16767                    <select id="scan_preset">
16768                      <option value="balanced">Balanced local scan</option>
16769                      <option value="code_focused">Code focused</option>
16770                      <option value="comment_audit">Comment audit</option>
16771                      <option value="deep_review">Deep review</option>
16772                    </select>
16773                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
16774                  </div>
16775                  <div class="explainer-card">
16776                    <div class="field-help-title">Selected scan preset</div>
16777                    <div class="explainer-body" id="scan-preset-description"></div>
16778                    <div class="preset-summary-row" id="scan-preset-summary"></div>
16779                    <div class="code-sample" id="scan-preset-example"></div>
16780                    <div class="preset-note" id="scan-preset-note"></div>
16781                  </div>
16782                </div>
16783                <hr class="step3-separator" />
16784                <div class="preset-kv-row">
16785                  <div class="toggle-card" style="margin:0;">
16786                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
16787                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
16788                    <select id="artifact_preset">
16789                      <option value="review">Review bundle</option>
16790                      <option value="full">Full bundle</option>
16791                      <option value="html_only">HTML only</option>
16792                      <option value="machine">Machine bundle</option>
16793                    </select>
16794                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
16795                  </div>
16796                  <div class="explainer-card">
16797                    <div class="field-help-title">Selected artifact preset</div>
16798                    <div class="explainer-body" id="artifact-preset-description"></div>
16799                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
16800                    <div class="code-sample" id="artifact-preset-example"></div>
16801                  </div>
16802                </div>
16803              </div>
16804
16805              <div class="section section-spacer-top">
16806                <div class="output-field-row">
16807                  <div class="field">
16808                    <label for="output_dir">Output directory</label>
16809                    {% if server_mode %}
16810                    <div class="input-group compact">
16811                      <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);" />
16812                    </div>
16813                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
16814                    {% else %}
16815                    <div class="input-group compact">
16816                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
16817                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
16818                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
16819                    </div>
16820                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
16821                    {% endif %}
16822                  </div>
16823                  <div class="output-field-aside">
16824                    <strong>Where reports land</strong>
16825                    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.
16826                  </div>
16827                </div>
16828              </div>
16829
16830              <div class="section section-spacer-top">
16831                <div class="output-field-row">
16832                  <div class="field">
16833                    <label for="report_title">Report title</label>
16834                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
16835                    <div class="hint">Appears in HTML and PDF output headers.</div>
16836                  </div>
16837                  <div class="output-field-aside">
16838                    <strong>Shown in exported artifacts</strong>
16839                    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.
16840                  </div>
16841                </div>
16842              </div>
16843
16844              <div class="section section-spacer-top">
16845                <div class="output-field-row">
16846                  <div class="field">
16847                    <label for="report_header_footer">Report header / footer</label>
16848                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
16849                    <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>
16850                  </div>
16851                  <div class="output-field-aside">
16852                    <strong>Page-level identification</strong>
16853                    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.
16854                  </div>
16855                </div>
16856              </div>
16857
16858              <div class="wizard-actions">
16859                <div class="left">
16860                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
16861                </div>
16862                <div class="right">
16863                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
16864                </div>
16865              </div>
16866            </div>
16867
16868            <div class="wizard-step" data-step="4">
16869              <div class="section">
16870                <div class="section-kicker">Step 4</div>
16871                <h2>Review selections and run</h2>
16872                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
16873                <div class="review-grid">
16874                  <div class="review-card highlight">
16875                    <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>
16876                    <ul id="review-scan-summary"></ul>
16877                  </div>
16878                  <div class="review-card highlight">
16879                    <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>
16880                    <ul id="review-count-summary"></ul>
16881                  </div>
16882                  <div class="review-card">
16883                    <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>
16884                    <ul id="review-artifact-summary"></ul>
16885                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
16886                  </div>
16887                  <div class="review-card">
16888                    <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>
16889                    <ul id="review-preview-summary"></ul>
16890                  </div>
16891                </div>
16892              </div>
16893
16894              <div class="wizard-actions">
16895                <div class="left">
16896                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
16897                </div>
16898                <div class="right">
16899                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
16900                </div>
16901              </div>
16902            </div>
16903            {% if server_mode %}
16904            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
16905            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
16906            {% endif %}
16907          </form>
16908        </div>
16909      </section>
16910    </div>
16911  </div>
16912
16913  <script nonce="{{ csp_nonce }}">
16914    (function () {
16915      function startScanPhase() {
16916        var phaseEl = document.getElementById("scan-phase");
16917        if (!phaseEl) return;
16918        var phases = [
16919          "Discovering files...",
16920          "Decoding file encodings...",
16921          "Detecting languages...",
16922          "Analyzing source lines...",
16923          "Applying counting policies...",
16924          "Aggregating results...",
16925          "Rendering report..."
16926        ];
16927        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
16928        var i = 0;
16929        function next() {
16930          phaseEl.style.opacity = "0";
16931          setTimeout(function () {
16932            phaseEl.textContent = phases[i];
16933            phaseEl.style.opacity = "0.85";
16934            var delay = durations[i] || 1800;
16935            i++;
16936            if (i < phases.length) { setTimeout(next, delay); }
16937          }, 200);
16938        }
16939        next();
16940      }
16941
16942      var form = document.getElementById("analyze-form");
16943      var loading = document.getElementById("loading");
16944      var submitButton = document.getElementById("submit-button");
16945      var pathInput = document.getElementById("path");
16946      var GIT_MODE = !!(pathInput && pathInput.readOnly);
16947      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
16948      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
16949      var outputDirInput = document.getElementById("output_dir");
16950      var reportTitleInput = document.getElementById("report_title");
16951      var previewPanel = document.getElementById("preview-panel");
16952      var refreshButton = document.getElementById("refresh-preview");
16953      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
16954      var useSamplePath = document.getElementById("use-sample-path");
16955      var useDefaultOutput = document.getElementById("use-default-output");
16956      var browsePath = document.getElementById("browse-path");
16957      var browseOutputDir = document.getElementById("browse-output-dir");
16958      var browseCoverage = document.getElementById("browse-coverage");
16959      var coverageInput = document.getElementById("coverage_file");
16960      var covScanStatus = document.getElementById("cov-scan-status");
16961      var coverageSuggestTimer = null;
16962      var covAutoFilled = false;
16963      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
16964
16965      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
16966      (function() {
16967        var ids = ["path", "output_dir"];
16968        ids.forEach(function(id) {
16969          var el = document.getElementById(id);
16970          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
16971        });
16972      }());
16973      function fmtBytes(b) {
16974        b = Number(b) || 0;
16975        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
16976        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
16977        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
16978        return b + ' B';
16979      }
16980      var themeToggle = document.getElementById("theme-toggle");
16981
16982      function showBannerToast(msg, isError, opts) {
16983        opts = opts || {};
16984        var t = document.createElement('div');
16985        t.className = isError ? 'toast-error' : 'toast-success';
16986        var topPos = opts.top ? '80px' : null;
16987        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
16988          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
16989          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
16990          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
16991        if (opts.icon) {
16992          var inner = document.createElement('span');
16993          inner.innerHTML = opts.icon + ' ';
16994          t.appendChild(inner);
16995        }
16996        t.appendChild(document.createTextNode(msg));
16997        document.body.appendChild(t);
16998        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
16999      }
17000      var mixedLinePolicy = document.getElementById("mixed_line_policy");
17001      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
17002      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
17003      var scanPreset = document.getElementById("scan_preset");
17004      var artifactPreset = document.getElementById("artifact_preset");
17005      var includeGlobsInput = document.getElementById("include_globs");
17006      var excludeGlobsInput = document.getElementById("exclude_globs");
17007
17008      // Include globs scope badge — updates reactively as the user types.
17009      (function() {
17010        var badge = document.getElementById("include-scope-badge");
17011        if (!badge || !includeGlobsInput) return;
17012        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> ';
17013        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> ';
17014        function update() {
17015          var val = includeGlobsInput.value.trim();
17016          if (!val) {
17017            badge.className = "include-scope-badge scope-all";
17018            badge.innerHTML = iconCheck + "All files eligible — no include filter active";
17019          } else {
17020            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
17021            badge.className = "include-scope-badge scope-narrow";
17022            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " — only matching files will be included";
17023          }
17024        }
17025        includeGlobsInput.addEventListener("input", update);
17026        update();
17027      }());
17028
17029      // Quick-exclude chips — append pattern to exclude_globs textarea.
17030      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
17031        chip.addEventListener("click", function() {
17032          var pattern = chip.getAttribute("data-pattern") || "";
17033          if (!pattern || !excludeGlobsInput) return;
17034          var current = excludeGlobsInput.value.trim();
17035          // For the "skip all" chip, replace any existing dep patterns cleanly.
17036          var patterns = pattern.split("\n");
17037          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
17038          var added = false;
17039          patterns.forEach(function(p) {
17040            p = p.trim();
17041            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
17042          });
17043          if (added) {
17044            excludeGlobsInput.value = lines.join("\n");
17045            excludeGlobsInput.dispatchEvent(new Event("input"));
17046          }
17047          chip.classList.add("active");
17048        });
17049      });
17050
17051      var liveReportTitle = document.getElementById("live-report-title");
17052      var navProjectPill = document.getElementById("nav-project-pill");
17053      var navProjectTitle = document.getElementById("nav-project-title");
17054      var reportTitlePreview = null;
17055      var wizardProgressFill = document.getElementById("wizard-progress-fill");
17056      var wizardProgressValue = document.getElementById("wizard-progress-value");
17057      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
17058      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
17059      var reportTitleTouched = false;
17060      var currentStep = 1;
17061      var previewTimer = null;
17062      var _previewGen = 0;
17063      var quickScanBtn = document.getElementById("quick-scan-btn");
17064
17065      function dismissAnalysisModal() {
17066        if (loading) loading.classList.remove("active");
17067        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17068          var el = document.getElementById(id);
17069          if (el) el.classList.add("hidden");
17070        });
17071        var cancelBtn = document.getElementById("lc-cancel-btn");
17072        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
17073        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
17074        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
17075        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
17076        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");}
17077        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
17078        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17079        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17080        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17081        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17082        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17083      }
17084
17085      var lcDismissBtn = document.getElementById("lc-dismiss");
17086      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
17087
17088      // When the browser restores this page from bfcache (Back button after navigating to results),
17089      // the loading overlay would still be showing its active state. Dismiss it immediately.
17090      window.addEventListener("pageshow", function(e) {
17091        if (e.persisted) { dismissAnalysisModal(); }
17092      });
17093
17094      function startAsyncAnalysis(formData) {
17095        var gitRepo = (formData.get("git_repo") || "").toString();
17096        var gitRef  = (formData.get("git_ref")  || "").toString();
17097        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
17098        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
17099
17100        var pathEl = document.getElementById("lc-path-text");
17101        if (pathEl) pathEl.textContent = displayPath;
17102
17103        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17104          var el = document.getElementById(id);
17105          if (el) el.classList.add("hidden");
17106        });
17107        var cancelBtn = document.getElementById("lc-cancel-btn");
17108        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
17109        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17110        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17111        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17112        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
17113        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
17114        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
17115        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");}
17116        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
17117
17118        if (loading) loading.classList.add("active");
17119
17120        var startTime = Date.now();
17121        var elapsedTimer = setInterval(function() {
17122          var s = Math.floor((Date.now() - startTime) / 1000);
17123          var el = document.getElementById("lc-elapsed");
17124          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
17125        }, 1000);
17126
17127        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
17128
17129        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();}
17130
17131        var PHASE_DESC = {
17132          'Starting': 'Initializing language analyzers and loading configuration…',
17133          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
17134          'Running': 'Running the lexical state machine across all discovered source files…',
17135          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
17136          'Done': 'Analysis complete — loading your results…',
17137          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
17138        };
17139        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
17140        function lcSetPhase(txt) {
17141          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
17142          var desc = document.getElementById("lc-stage-desc");
17143          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
17144          var step = PHASE_STEP[txt] || 1;
17145          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");}
17146        }
17147
17148        function lcShowCancelled() {
17149          clearInterval(elapsedTimer);
17150          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
17151          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
17152          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
17153          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
17154          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
17155          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
17156          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
17157          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
17158          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17159          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17160        }
17161
17162        var lcCancelBtn = document.getElementById("lc-cancel-btn");
17163        if (lcCancelBtn) {
17164          lcCancelBtn.onclick = function() {
17165            if (!activeWaitId) { dismissAnalysisModal(); return; }
17166            lcCancelBtn.disabled = true;
17167            lcCancelBtn.textContent = "Cancelling…";
17168            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
17169              .then(function() { lcShowCancelled(); })
17170              .catch(function() { lcShowCancelled(); });
17171          };
17172        }
17173
17174        function lcShowError(msg) {
17175          clearInterval(elapsedTimer);
17176          lcSetPhase("Failed");
17177          var msgEl = document.getElementById("lc-err-msg");
17178          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
17179          var errEl = document.getElementById("lc-err");
17180          var actEl = document.getElementById("lc-actions");
17181          if (errEl) errEl.classList.remove("hidden");
17182          if (actEl) actEl.classList.remove("hidden");
17183          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17184          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17185        }
17186
17187        function lcPoll(waitId) {
17188          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
17189            .then(function(r) {
17190              if (!r.ok) throw new Error("HTTP " + r.status);
17191              return r.json();
17192            })
17193            .then(function(data) {
17194              pollRetries = 0;
17195              if (data.state === "complete") {
17196                clearInterval(elapsedTimer);
17197                lcSetPhase("Done");
17198                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
17199              } else if (data.state === "failed") {
17200                lcShowError(data.message);
17201              } else if (data.state === "cancelled") {
17202                lcShowCancelled();
17203              } else {
17204                var s = Math.floor((Date.now() - startTime) / 1000);
17205                if (s > 90 && !warnShown) {
17206                  warnShown = true;
17207                  var w = document.getElementById("lc-warn");
17208                  if (w) w.classList.remove("hidden");
17209                }
17210                lcSetPhase(data.phase || "Running");
17211                var fd = data.files_done || 0, ft = data.files_total || 0;
17212                if (ft > 0) {
17213                  var card = document.getElementById("lc-files-card");
17214                  if (card) card.classList.remove("hidden");
17215                  var el = document.getElementById("lc-files");
17216                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
17217                  var now = Date.now();
17218                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
17219                  if (fdelta > 0 && tdelta > 0.4) {
17220                    var fps = Math.round(fdelta / tdelta);
17221                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
17222                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
17223                  }
17224                  lastFd = fd; lastFdTime = now;
17225                }
17226                setTimeout(function() { lcPoll(waitId); }, 1500);
17227              }
17228            })
17229            .catch(function() {
17230              pollRetries++;
17231              if (pollRetries >= 5) {
17232                lcShowError("Lost connection to server. Reload to check status.");
17233              } else {
17234                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
17235              }
17236            });
17237        }
17238
17239        var params = new URLSearchParams(formData);
17240        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
17241          .then(function(r) {
17242            var waitId = r.headers.get("x-wait-id");
17243            if (!waitId) { window.location.href = "/scan"; return; }
17244            activeWaitId = waitId;
17245            setTimeout(function() { lcPoll(waitId); }, 1500);
17246          })
17247          .catch(function(err) {
17248            lcShowError("Could not reach server: " + (err.message || err));
17249          });
17250      }
17251
17252      if (quickScanBtn) {
17253        quickScanBtn.addEventListener("click", function () {
17254          var pathVal = pathInput ? pathInput.value.trim() : "";
17255          if (!pathVal) {
17256            alert("Please enter or browse to a project path first.");
17257            return;
17258          }
17259          quickScanBtn.disabled = true;
17260          quickScanBtn.textContent = "Scanning...";
17261          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
17262          startAsyncAnalysis(new FormData(form));
17263        });
17264      }
17265
17266      var mixedPolicyInfo = {
17267        code_only: {
17268          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.",
17269          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'
17270        },
17271        code_and_comment: {
17272          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.",
17273          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'
17274        },
17275        comment_only: {
17276          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.",
17277          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'
17278        },
17279        separate_mixed_category: {
17280          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.",
17281          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'
17282        }
17283      };
17284
17285      var scanPresetInfo = {
17286        balanced: {
17287          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.",
17288          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
17289          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
17290          note: "Best when you want a stable local overview before making deeper adjustments.",
17291          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17292        },
17293        code_focused: {
17294          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
17295          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
17296          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
17297          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
17298          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17299        },
17300        comment_audit: {
17301          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
17302          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
17303          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
17304          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
17305          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17306        },
17307        deep_review: {
17308          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
17309          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
17310          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
17311          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
17312          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
17313        }
17314      };
17315
17316      var artifactPresetInfo = {
17317        review: {
17318          description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
17319          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
17320          example: "Ideal for a quick local review before sharing results."
17321        },
17322        full: {
17323          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
17324          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
17325          example: "Use when producing a deliverable or storing a snapshot for future comparison."
17326        },
17327        html_only: {
17328          description: "Standalone HTML report only. No PDF generation, no data files.",
17329          chips: ["HTML only"],
17330          example: "Fastest option when you only need to open the report in a browser."
17331        },
17332        machine: {
17333          description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
17334          chips: ["JSON", "CSV", "no HTML", "no PDF"],
17335          example: "Use in CI to capture metrics without generating visual reports."
17336        }
17337      };
17338
17339      function applyArtifactPreset() {
17340        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
17341        if (!info) return;
17342        var descEl = document.getElementById("artifact-preset-description");
17343        var exampleEl = document.getElementById("artifact-preset-example");
17344        if (descEl) descEl.textContent = info.description;
17345        if (exampleEl) exampleEl.textContent = info.example;
17346        renderPresetChips("artifact-preset-summary", info.chips);
17347      }
17348
17349      function applyTheme(theme) {
17350        if (theme === "dark") document.body.classList.add("dark-theme");
17351        else document.body.classList.remove("dark-theme");
17352      }
17353
17354      function loadSavedTheme() {
17355        var saved = null;
17356        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
17357        applyTheme(saved === "dark" ? "dark" : "light");
17358      }
17359
17360      function updateScrollProgress() {
17361        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
17362        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
17363        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
17364        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
17365        var step = Math.min(Math.max(currentStep, 1), 4);
17366        var base = stepBase[step];
17367        var end  = stepEnd[step];
17368
17369        var scrollFrac = 0;
17370        var activePanel = document.querySelector(".wizard-step.active");
17371        if (activePanel) {
17372          var scrollTop = window.scrollY || window.pageYOffset || 0;
17373          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
17374          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
17375          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
17376          var scrolled = scrollTop + viewH - panelTop;
17377          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
17378        }
17379
17380        var percent = Math.round(base + (end - base) * scrollFrac);
17381        percent = Math.min(end, Math.max(base, percent));
17382        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
17383        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
17384      }
17385
17386      function updateWizardProgress() {
17387        updateScrollProgress();
17388      }
17389
17390      var stepDescriptions = [
17391        "Choose a project folder, apply scope filters, and preview which files will be counted.",
17392        "Configure how mixed code-plus-comment lines and docstrings are classified.",
17393        "Pick your output formats, scan preset, and where reports are saved.",
17394        "Review all settings and launch the analysis."
17395      ];
17396
17397      function updateStepNav(step) {
17398        var infoLabel = document.getElementById("step-nav-info-label");
17399        var infoDesc  = document.getElementById("step-nav-info-desc");
17400        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
17401        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
17402      }
17403
17404      function updateSidebarSummary() {
17405        var sumPath    = document.getElementById("sum-path");
17406        var sumPreset  = document.getElementById("sum-preset");
17407        var sumOutput  = document.getElementById("sum-output");
17408        var sidebarSummary = document.getElementById("sidebar-summary");
17409        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
17410        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
17411        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
17412        if (sumPath)   sumPath.textContent   = pathVal   || "—";
17413        if (sumPreset) sumPreset.textContent = presetVal || "—";
17414        if (sumOutput) sumOutput.textContent = outputVal || "—";
17415        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
17416      }
17417
17418      function setStep(step, pushHistory) {
17419        currentStep = step;
17420        stepPanels.forEach(function (panel) {
17421          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
17422        });
17423        stepButtons.forEach(function (button) {
17424          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
17425        });
17426        var layoutEl = document.querySelector(".layout");
17427        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
17428        updateWizardProgress();
17429        updateStepNav(step);
17430        stepButtons.forEach(function(btn) {
17431          var t = Number(btn.getAttribute("data-step-target"));
17432          btn.classList.toggle("done", t < step);
17433        });
17434        updateSidebarSummary();
17435
17436        if (pushHistory !== false) {
17437          try {
17438            history.pushState({ wizardStep: step }, "", "#step" + step);
17439          } catch (e) {}
17440        }
17441
17442        window.scrollTo({ top: 0, behavior: "instant" });
17443      }
17444
17445      window.addEventListener("popstate", function (e) {
17446        if (e.state && e.state.wizardStep) {
17447          setStep(e.state.wizardStep, false);
17448        } else {
17449          var hashMatch = location.hash.match(/^#step([1-4])$/);
17450          if (hashMatch) setStep(Number(hashMatch[1]), false);
17451        }
17452      });
17453
17454      function inferTitleFromPath(value) {
17455        if (!value) return "project";
17456        var cleaned = value.replace(/[\/\\]+$/, "");
17457        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
17458        return parts.length ? parts[parts.length - 1] : value;
17459      }
17460
17461      function updateReportTitleFromPath() {
17462        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
17463        if (!reportTitleTouched) {
17464          reportTitleInput.value = inferred;
17465        }
17466        var title = reportTitleInput.value || inferred;
17467        if (liveReportTitle) liveReportTitle.textContent = title;
17468        if (reportTitlePreview) reportTitlePreview.textContent = title;
17469        document.title = "OxideSLOC | " + title;
17470
17471        var projectPath = (pathInput.value || "").trim();
17472        if (navProjectPill && navProjectTitle) {
17473          if (projectPath.length > 0) {
17474            navProjectTitle.textContent = inferred;
17475            navProjectPill.classList.add("visible");
17476          } else {
17477            navProjectTitle.textContent = "";
17478            navProjectPill.classList.remove("visible");
17479          }
17480        }
17481      }
17482
17483      function updateMixedPolicyUI() {
17484        var key = mixedLinePolicy.value || "code_only";
17485        var info = mixedPolicyInfo[key];
17486        document.getElementById("mixed-policy-description").textContent = info.description;
17487        document.getElementById("mixed-policy-example").textContent = info.example;
17488      }
17489
17490      function updatePythonDocstringUI() {
17491        var checked = !!pythonDocstrings.checked;
17492        document.getElementById("python-docstring-example").textContent = checked
17493          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
17494          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
17495        document.getElementById("python-docstring-live-help").textContent = checked
17496          ? "Enabled: docstrings contribute to comment-style totals."
17497          : "Disabled: docstrings are not counted as comment content.";
17498      }
17499
17500      function renderPresetChips(targetId, chips) {
17501        var target = document.getElementById(targetId);
17502        if (!target) return;
17503        target.innerHTML = (chips || []).map(function (chip) {
17504          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
17505        }).join('');
17506      }
17507
17508      function updatePresetDescriptions() {
17509        var scanInfo = scanPresetInfo[scanPreset.value];
17510        if (!scanInfo) return;
17511        document.getElementById("scan-preset-description").textContent = scanInfo.description;
17512        document.getElementById("scan-preset-example").textContent = scanInfo.example;
17513        document.getElementById("scan-preset-note").textContent = scanInfo.note;
17514        renderPresetChips("scan-preset-summary", scanInfo.chips);
17515      }
17516
17517      function applyScanPreset() {
17518        var info = scanPresetInfo[scanPreset.value];
17519        if (!info || !info.apply) return;
17520        mixedLinePolicy.value = info.apply.mixed;
17521        pythonDocstrings.checked = !!info.apply.docstrings;
17522        document.getElementById("generated_file_detection").value = info.apply.generated;
17523        document.getElementById("minified_file_detection").value = info.apply.minified;
17524        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
17525        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
17526        document.getElementById("binary_file_behavior").value = info.apply.binary;
17527        updateMixedPolicyUI();
17528        updatePythonDocstringUI();
17529      }
17530
17531      function updateReview() {
17532        var scanSummary = document.getElementById("review-scan-summary");
17533        var countSummary = document.getElementById("review-count-summary");
17534        var artifactSummary = document.getElementById("review-artifact-summary");
17535        var outputSummary = document.getElementById("review-output-summary");
17536        var previewSummary = document.getElementById("review-preview-summary");
17537        var readinessSummary = document.getElementById("review-readiness-summary");
17538        var includeText = document.getElementById("include_globs").value.trim();
17539        var excludeText = document.getElementById("exclude_globs").value.trim();
17540        var sidePathPreview = document.getElementById("side-path-preview");
17541        var sideOutputPreview = document.getElementById("side-output-preview");
17542        var sideTitlePreview = document.getElementById("side-title-preview");
17543
17544        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
17545        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
17546        if (sideTitlePreview) {
17547          var rt = document.getElementById("report_title");
17548          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
17549        }
17550
17551        scanSummary.innerHTML = ""
17552          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
17553          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
17554          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
17555
17556        countSummary.innerHTML = ""
17557          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
17558          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
17559          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
17560          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
17561          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
17562          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
17563          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
17564          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
17565
17566        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
17567
17568        outputSummary.innerHTML = ""
17569          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
17570          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
17571
17572        if (previewSummary) {
17573          if (GIT_MODE) {
17574            previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode — the repository will be cloned and fully analyzed during the scan run.</li>';
17575          } else {
17576          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
17577          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
17578          var statMap = {};
17579          statButtons.forEach(function (button) {
17580            var valueNode = button.querySelector('.scope-stat-value');
17581            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
17582          });
17583          previewSummary.innerHTML = ''
17584            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
17585            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
17586            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
17587            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
17588            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
17589            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
17590
17591          if (readinessSummary) {
17592            readinessSummary.innerHTML = ''
17593              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
17594              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
17595              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
17596          }
17597          } // end else (non-GIT_MODE)
17598        }
17599      }
17600
17601      function escapeHtml(value) {
17602        return String(value)
17603          .replace(/&/g, "&amp;")
17604          .replace(/</g, "&lt;")
17605          .replace(/>/g, "&gt;")
17606          .replace(/"/g, "&quot;")
17607          .replace(/'/g, "&#39;");
17608      }
17609
17610      function isPythonVisible() {
17611        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
17612      }
17613
17614      function syncPythonVisibility() {
17615        var html = previewPanel.textContent || "";
17616        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
17617        pythonWraps.forEach(function (node) {
17618          node.classList.toggle("hidden", !hasPython);
17619        });
17620      }
17621
17622      function attachPreviewInteractions() {
17623        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
17624        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
17625        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
17626        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
17627        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
17628        var searchInput = previewPanel.querySelector("#explorer-search");
17629        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
17630        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
17631        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
17632        var activeFilter = "all";
17633        var activeLanguage = "";
17634        var searchTerm = "";
17635        var currentSortKey = null;
17636        var currentSortOrder = "asc";
17637        var childRows = {};
17638
17639        rows.forEach(function (row) {
17640          var parentId = row.getAttribute("data-parent-id") || "";
17641          var rowId = row.getAttribute("data-row-id") || "";
17642          if (!childRows[parentId]) childRows[parentId] = [];
17643          childRows[parentId].push(rowId);
17644        });
17645
17646        function rowById(id) {
17647          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
17648        }
17649
17650        function hasCollapsedAncestor(row) {
17651          var parentId = row.getAttribute("data-parent-id");
17652          while (parentId) {
17653            var parent = rowById(parentId);
17654            if (!parent) break;
17655            if (parent.getAttribute("data-expanded") === "false") return true;
17656            parentId = parent.getAttribute("data-parent-id");
17657          }
17658          return false;
17659        }
17660
17661        function updateToggleGlyph(row) {
17662          var toggle = row.querySelector(".tree-toggle");
17663          if (!toggle) return;
17664          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
17665        }
17666
17667        function rowSortValue(row, key) {
17668          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
17669        }
17670
17671        function updateSortButtons() {
17672          sortButtons.forEach(function (button) {
17673            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
17674            var indicator = button.querySelector(".tree-sort-indicator");
17675            button.classList.toggle("active", isActive);
17676            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
17677            if (indicator) {
17678              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
17679            }
17680          });
17681        }
17682
17683        function sortSiblingRows() {
17684          if (!treeContainer) {
17685            updateSortButtons();
17686            return;
17687          }
17688
17689          var rowMap = {};
17690          var childrenMap = {};
17691          rows.forEach(function (row) {
17692            var rowId = row.getAttribute("data-row-id");
17693            var parentId = row.getAttribute("data-parent-id") || "";
17694            rowMap[rowId] = row;
17695            if (!childrenMap[parentId]) childrenMap[parentId] = [];
17696            childrenMap[parentId].push(rowId);
17697          });
17698
17699          Object.keys(childrenMap).forEach(function (parentId) {
17700            if (!parentId) return;
17701            childrenMap[parentId].sort(function (a, b) {
17702              var rowA = rowMap[a];
17703              var rowB = rowMap[b];
17704              if (!currentSortKey) {
17705                return Number(a) - Number(b);
17706              }
17707              var valueA = rowSortValue(rowA, currentSortKey);
17708              var valueB = rowSortValue(rowB, currentSortKey);
17709              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
17710              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
17711              var fallbackA = rowSortValue(rowA, "name");
17712              var fallbackB = rowSortValue(rowB, "name");
17713              if (fallbackA < fallbackB) return -1;
17714              if (fallbackA > fallbackB) return 1;
17715              return Number(a) - Number(b);
17716            });
17717          });
17718
17719          var orderedIds = [];
17720          function pushChildren(parentId) {
17721            (childrenMap[parentId] || []).forEach(function (childId) {
17722              orderedIds.push(childId);
17723              pushChildren(childId);
17724            });
17725          }
17726
17727          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
17728            orderedIds.push(topId);
17729            pushChildren(topId);
17730          });
17731
17732          orderedIds.forEach(function (id) {
17733            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
17734          });
17735          updateSortButtons();
17736        }
17737
17738        function updateLanguageButtons() {
17739          languageButtons.forEach(function (button) {
17740            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
17741            var isActive = languageValue === activeLanguage;
17742            button.classList.toggle("active", isActive);
17743          });
17744        }
17745
17746        function rowSelfMatches(row) {
17747          var kind = row.getAttribute("data-kind");
17748          var status = row.getAttribute("data-status");
17749          var language = (row.getAttribute("data-language") || "").toLowerCase();
17750          var name = row.getAttribute("data-name-lower") || "";
17751          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
17752          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
17753          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
17754          var passesLanguage = !activeLanguage || language === activeLanguage;
17755          return passesFilter && passesSearch && passesLanguage;
17756        }
17757
17758        function hasMatchingDescendant(rowId) {
17759          return (childRows[rowId] || []).some(function (childId) {
17760            var childRow = rowById(childId);
17761            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
17762          });
17763        }
17764
17765        function rowMatches(row) {
17766          if (rowSelfMatches(row)) return true;
17767          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
17768        }
17769
17770        function resetViewState() {
17771          activeFilter = "all";
17772          activeLanguage = "";
17773          searchTerm = "";
17774          currentSortKey = null;
17775          currentSortOrder = "asc";
17776          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17777          if (searchInput) searchInput.value = "";
17778          if (filterSelect) filterSelect.value = "all";
17779          updateLanguageButtons();
17780        }
17781
17782        function applyVisibility() {
17783          rows.forEach(function (row) {
17784            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
17785            row.classList.toggle("hidden-by-filter", !visible);
17786            row.style.display = visible ? "grid" : "none";
17787          });
17788          buttons.forEach(function (button) {
17789            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
17790          });
17791          if (filterSelect) filterSelect.value = activeFilter;
17792        }
17793
17794        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
17795        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
17796        var originalStats = {};
17797        buttons.forEach(function (btn) {
17798          var f = btn.getAttribute('data-filter');
17799          var v = btn.querySelector('.scope-stat-value');
17800          if (f && v) originalStats[f] = v.textContent;
17801        });
17802
17803        function applySubmoduleStats(statsJson) {
17804          try {
17805            var s = JSON.parse(statsJson);
17806            buttons.forEach(function (btn) {
17807              var f = btn.getAttribute('data-filter');
17808              var v = btn.querySelector('.scope-stat-value');
17809              if (!v) return;
17810              if (f === 'dir') v.textContent = s.dirs;
17811              else if (f === 'file') v.textContent = s.files;
17812              else if (f === 'supported') v.textContent = s.supported;
17813              else if (f === 'skipped') v.textContent = s.skipped;
17814              else if (f === 'unsupported') v.textContent = s.unsupported;
17815            });
17816          } catch (e) {}
17817        }
17818
17819        function restoreBaseRepoStats() {
17820          buttons.forEach(function (btn) {
17821            var f = btn.getAttribute('data-filter');
17822            var v = btn.querySelector('.scope-stat-value');
17823            if (v && originalStats[f]) v.textContent = originalStats[f];
17824          });
17825          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17826          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
17827        }
17828
17829        submoduleChips.forEach(function (chip) {
17830          chip.addEventListener('click', function () {
17831            var statsJson = chip.getAttribute('data-sub-stats');
17832            if (!statsJson) return;
17833            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17834            chip.classList.add('active');
17835            applySubmoduleStats(statsJson);
17836            if (baseRepoBtn) baseRepoBtn.style.display = '';
17837          });
17838        });
17839
17840        if (baseRepoBtn) {
17841          baseRepoBtn.addEventListener('click', function () {
17842            restoreBaseRepoStats();
17843            resetViewState();
17844            sortSiblingRows();
17845            applyVisibility();
17846          });
17847        }
17848
17849        buttons.forEach(function (button) {
17850          button.addEventListener("click", function () {
17851            var filterValue = button.getAttribute("data-filter") || "all";
17852            if (filterValue === "reset-view") {
17853              restoreBaseRepoStats();
17854              resetViewState();
17855              sortSiblingRows();
17856              applyVisibility();
17857              return;
17858            }
17859            activeFilter = filterValue;
17860            applyVisibility();
17861          });
17862        });
17863
17864        rows.forEach(function (row) {
17865          updateToggleGlyph(row);
17866          var toggle = row.querySelector(".tree-toggle");
17867          if (toggle) {
17868            toggle.addEventListener("click", function () {
17869              var expanded = row.getAttribute("data-expanded") !== "false";
17870              row.setAttribute("data-expanded", expanded ? "false" : "true");
17871              updateToggleGlyph(row);
17872              applyVisibility();
17873            });
17874          }
17875        });
17876
17877        actionButtons.forEach(function (button) {
17878          button.addEventListener("click", function () {
17879            var action = button.getAttribute("data-explorer-action");
17880            if (action === "expand-all") {
17881              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17882            } else if (action === "collapse-all") {
17883              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
17884            } else if (action === "clear-filters") {
17885              resetViewState();
17886            }
17887            sortSiblingRows();
17888            applyVisibility();
17889          });
17890        });
17891
17892        if (filterSelect) {
17893          filterSelect.addEventListener("change", function () {
17894            activeFilter = filterSelect.value || "all";
17895            applyVisibility();
17896          });
17897        }
17898
17899        languageButtons.forEach(function (button) {
17900          button.addEventListener("click", function () {
17901            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
17902            updateLanguageButtons();
17903            applyVisibility();
17904          });
17905        });
17906
17907        sortButtons.forEach(function (button) {
17908          button.addEventListener("click", function () {
17909            var sortKey = button.getAttribute("data-sort-key");
17910            if (currentSortKey === sortKey) {
17911              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
17912            } else {
17913              currentSortKey = sortKey;
17914              currentSortOrder = "asc";
17915            }
17916            sortSiblingRows();
17917            applyVisibility();
17918          });
17919        });
17920
17921        if (searchInput) {
17922          searchInput.addEventListener("input", function () {
17923            searchTerm = searchInput.value.trim().toLowerCase();
17924            applyVisibility();
17925          });
17926        }
17927
17928        updateLanguageButtons();
17929        sortSiblingRows();
17930        applyVisibility();
17931      }
17932
17933      function loadPreview() {
17934        if (!previewPanel || !pathInput) return;
17935        if (GIT_MODE) {
17936          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>';
17937          return;
17938        }
17939        var path = pathInput.value.trim();
17940        var zeroWarn = document.getElementById('zero-files-warning');
17941        if (!path) {
17942          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
17943          if (zeroWarn) zeroWarn.style.display = 'none';
17944          return;
17945        }
17946        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
17947        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
17948        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
17949        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
17950        var myGen = ++_previewGen;
17951        var _prevMsgs = [
17952          'Scanning directory structure…',
17953          'Detecting file types…',
17954          'Applying include / exclude filters…',
17955          'Estimating file counts…',
17956          'Building scope preview…',
17957          'Almost there…'
17958        ];
17959        var _prevMsgIdx = 0;
17960        var _prevStart = Date.now();
17961        previewPanel.innerHTML =
17962          '<div class="preview-loading">' +
17963          '<div class="preview-spinner"></div>' +
17964          '<div class="preview-loading-text">' +
17965          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
17966          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
17967          '</div></div>';
17968        var _sizeTextEl = document.getElementById('project-size-text');
17969        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
17970        window._previewInterval = setInterval(function() {
17971          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
17972          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
17973          var ml = document.getElementById('plm');
17974          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
17975        }, 1500);
17976        window._previewElapsedTimer = setInterval(function() {
17977          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
17978          var el = document.getElementById('ple');
17979          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
17980        }, 1000);
17981        var previewUrl = "/preview?path=" + encodeURIComponent(path)
17982          + "&include_globs=" + encodeURIComponent(includeValue)
17983          + "&exclude_globs=" + encodeURIComponent(excludeValue);
17984        fetch(previewUrl)
17985          .then(function (response) { return response.text(); })
17986          .then(function (html) {
17987            if (myGen !== _previewGen) return;
17988            clearInterval(window._previewInterval); window._previewInterval = null;
17989            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17990            previewPanel.innerHTML = html;
17991            attachPreviewInteractions();
17992            syncPythonVisibility();
17993            updateReview();
17994            setTimeout(collapseLanguagePills, 50);
17995            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
17996            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
17997            var sizeText = document.getElementById('project-size-text');
17998            var sizeBtn = document.getElementById('project-size-btn');
17999            // In server mode with upload sizes available, keep the compressed/original pair.
18000            if (SERVER_MODE && window._lastUploadSizes) {
18001              var us = window._lastUploadSizes;
18002              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
18003                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
18004              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
18005                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
18006            } else if (sizeText && projectSize) {
18007              sizeText.textContent = 'Project size: ' + projectSize;
18008              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
18009            } else if (sizeText) {
18010              sizeText.textContent = 'Project size: —';
18011            }
18012            if (zeroWarn) {
18013              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
18014              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
18015              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
18016              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
18017              if (supportedCount === 0 && fileCount > 0) {
18018                zeroWarn.textContent = '⚠ Warning: No supported source files detected—this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
18019                zeroWarn.style.display = '';
18020              } else {
18021                zeroWarn.style.display = 'none';
18022              }
18023            }
18024          })
18025          .catch(function (err) {
18026            if (myGen !== _previewGen) return;
18027            clearInterval(window._previewInterval); window._previewInterval = null;
18028            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
18029            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
18030          });
18031      }
18032
18033      function pickDirectory(targetInput, kind) {
18034        if (!targetInput) {
18035          showBannerToast("Directory picker: input element not found.", true);
18036          return;
18037        }
18038        if (SERVER_MODE) {
18039          if (kind === 'output') {
18040            showBannerToast(
18041              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
18042              false,
18043              { top: true, icon: '📁' }
18044            );
18045            return;
18046          }
18047          var inputEl = kind === 'coverage'
18048            ? document.getElementById('cov-upload-input')
18049            : document.getElementById('dir-upload-input');
18050          if (!inputEl) return;
18051          inputEl.onchange = function () {
18052            var files = inputEl.files;
18053            if (!files || files.length === 0) return;
18054            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
18055            if (browseBtn) browseBtn.disabled = true;
18056
18057            function fileToBase64(file) {
18058              return new Promise(function (resolve, reject) {
18059                var reader = new FileReader();
18060                reader.onload = function () {
18061                  var b64 = reader.result.split(',')[1];
18062                  resolve(b64);
18063                };
18064                reader.onerror = reject;
18065                reader.readAsDataURL(file);
18066              });
18067            }
18068
18069            if (kind === 'coverage') {
18070              var f = files[0];
18071              if (previewPanel && targetInput === pathInput)
18072                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
18073              fileToBase64(f).then(function (b64) {
18074                return fetch('/api/upload-file', {
18075                  method: 'POST',
18076                  headers: { 'Content-Type': 'application/json' },
18077                  body: JSON.stringify({ filename: f.name, content: b64 })
18078                }).then(function (r) { return r.json(); });
18079              })
18080                .then(function (d) {
18081                  if (d && d.tmp_path) {
18082                    if (coverageInput) coverageInput.value = d.tmp_path;
18083                    setCovStatus('idle');
18084                  } else if (d && d.error) { showBannerToast(d.error, true); }
18085                })
18086                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
18087                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
18088            } else {
18089              // ── Filter to source-code files only ─────────────────────────
18090              // Binary, generated, and dependency files (node_modules, .git,
18091              // build artifacts) are skipped so they are never uploaded.
18092              var CODE_EXTS = new Set([
18093                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18094                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18095                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18096                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18097                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18098                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
18099                'tf','hcl','proto','thrift','avsc','graphql','gql'
18100              ]);
18101              var codeFiles = [];
18102              for (var i = 0; i < files.length; i++) {
18103                var f = files[i];
18104                var name = f.name;
18105                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
18106                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
18107                  codeFiles.push(f); continue;
18108                }
18109                var dot = name.lastIndexOf('.');
18110                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
18111              }
18112              // Collect specific .git metadata files for server-side git detection.
18113              // These have no source extension so they are excluded by the loop above,
18114              // but the server needs them to read branch/commit/author without running git.
18115              var gitMetaFiles = [];
18116              for (var i = 0; i < files.length; i++) {
18117                var f = files[i];
18118                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
18119                var gitIdx = rp.indexOf('/.git/');
18120                if (gitIdx < 0) continue;
18121                var gitRel = rp.slice(gitIdx + 1);
18122                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
18123                    gitRel === '.git/logs/HEAD' ||
18124                    gitRel.startsWith('.git/refs/heads/') ||
18125                    gitRel.startsWith('.git/refs/tags/')) {
18126                  gitMetaFiles.push(f);
18127                }
18128              }
18129              var uploadFiles = codeFiles.concat(gitMetaFiles);
18130              var total = files.length;
18131              var kept = codeFiles.length;
18132              if (kept === 0) {
18133                if (previewPanel && targetInput === pathInput)
18134                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
18135                if (browseBtn) browseBtn.disabled = false;
18136                inputEl.value = '';
18137                return;
18138              }
18139
18140              // ── Helper: apply upload result to UI ────────────────────────
18141              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
18142              function applyUploadResult(tmpPath, sizes) {
18143                targetInput.value = tmpPath;
18144                scrollInputToEnd(targetInput);
18145                if (sizes && SERVER_MODE) {
18146                  window._lastUploadSizes = sizes;
18147                  // Immediately show both sizes before preview loads.
18148                  var sizeText = document.getElementById('project-size-text');
18149                  var sizeBtn = document.getElementById('project-size-btn');
18150                  if (sizeText) {
18151                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18152                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18153                  }
18154                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18155                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18156                }
18157                if (targetInput === pathInput) {
18158                  updateReportTitleFromPath();
18159                  autoSetOutputDir(tmpPath);
18160                  fetchProjectHistory(tmpPath);
18161                  loadPreview();
18162                  suggestCoverageFile(tmpPath);
18163                }
18164                updateReview();
18165                if (browseBtn) browseBtn.disabled = false;
18166                inputEl.value = '';
18167              }
18168
18169              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
18170              if (typeof CompressionStream !== 'undefined') {
18171                if (previewPanel && targetInput === pathInput)
18172                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18173
18174                // Build a minimal POSIX ustar tar header for a single file entry.
18175                function buildUstarHeader(filePath, fileSize) {
18176                  var BLOCK = 512;
18177                  var hdr = new Uint8Array(BLOCK);
18178                  var enc = new TextEncoder();
18179                  function wStr(off, len, s) {
18180                    var b = enc.encode(s);
18181                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
18182                  }
18183                  function wOct(off, len, val) {
18184                    var s = val.toString(8);
18185                    while (s.length < len - 1) s = '0' + s;
18186                    wStr(off, len, s + '\0');
18187                  }
18188                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
18189                  var name = filePath, prefix = '';
18190                  if (filePath.length > 99) {
18191                    var split = filePath.lastIndexOf('/', 154);
18192                    if (split > 0 && filePath.length - split - 1 <= 99) {
18193                      prefix = filePath.substring(0, split);
18194                      name   = filePath.substring(split + 1);
18195                    } else { name = filePath.substring(0, 99); }
18196                  }
18197                  wStr(0,   100, name);          // name
18198                  wOct(100,   8, 0o000644);      // mode
18199                  wOct(108,   8, 0);             // uid
18200                  wOct(116,   8, 0);             // gid
18201                  wOct(124,  12, fileSize);      // size
18202                  wOct(136,  12, 0);             // mtime (epoch)
18203                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
18204                  hdr[156] = 48;                 // type flag '0' = regular file
18205                  wStr(157, 100, '');            // linkname
18206                  wStr(257,   6, 'ustar');       // magic
18207                  wStr(263,   2, '00');          // version
18208                  wStr(265,  32, '');            // uname
18209                  wStr(297,  32, '');            // gname
18210                  wOct(329,   8, 0);             // devmajor
18211                  wOct(337,   8, 0);             // devminor
18212                  wStr(345, 155, prefix);        // prefix
18213                  // Compute checksum (sum of all bytes, placeholder = 32).
18214                  var chk = 0;
18215                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18216                  var cs = chk.toString(8);
18217                  while (cs.length < 6) cs = '0' + cs;
18218                  wStr(148, 8, cs + '\0 ');
18219                  return hdr;
18220                }
18221
18222                // Build tar.gz one file at a time, piping through CompressionStream.
18223                // RAM usage = compressed output buffer + one file at a time.
18224                (async function () {
18225                  try {
18226                    var BLOCK = 512;
18227                    var cs     = new CompressionStream('gzip');
18228                    var writer = cs.writable.getWriter();
18229                    var chunks = [];
18230                    var reader = cs.readable.getReader();
18231                    var collecting = (async function () {
18232                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
18233                    })();
18234
18235                    for (var i = 0; i < uploadFiles.length; i++) {
18236                      var file = uploadFiles[i];
18237                      var path = file.webkitRelativePath || file.name;
18238                      var buf  = await file.arrayBuffer();
18239                      var data = new Uint8Array(buf);
18240                      // Header block
18241                      await writer.write(buildUstarHeader(path, data.length));
18242                      // Data padded to 512-byte boundary
18243                      if (data.length > 0) {
18244                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
18245                        var block  = new Uint8Array(padded);
18246                        block.set(data);
18247                        await writer.write(block);
18248                      }
18249                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
18250                        if (previewPanel && targetInput === pathInput)
18251                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18252                      }
18253                    }
18254                    // End-of-archive: two 512-byte zero blocks
18255                    await writer.write(new Uint8Array(BLOCK * 2));
18256                    await writer.close();
18257                    await collecting;
18258
18259                    var blob = new Blob(chunks, { type: 'application/gzip' });
18260                    var sizeMB = (blob.size / 1048576).toFixed(1);
18261                    if (previewPanel && targetInput === pathInput)
18262                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
18263
18264                    var resp = await fetch('/api/upload-tarball', {
18265                      method: 'POST',
18266                      headers: { 'Content-Type': 'application/gzip' },
18267                      body: blob
18268                    });
18269                    var d = await resp.json();
18270                    if (d && d.tmp_path) {
18271                      applyUploadResult(d.tmp_path, {
18272                        compressed_bytes: d.compressed_bytes || 0,
18273                        original_bytes: d.original_bytes || 0
18274                      });
18275                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18276                  } catch (e) {
18277                    showBannerToast('Upload failed: ' + String(e), true);
18278                    if (browseBtn) browseBtn.disabled = false;
18279                    inputEl.value = '';
18280                  }
18281                })();
18282
18283              } else {
18284                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
18285                // Used only on browsers that lack CompressionStream (pre-2023).
18286                var BATCH = 200;
18287                var batches = [];
18288                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
18289                var totalBatches = batches.length;
18290                if (previewPanel && targetInput === pathInput)
18291                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
18292
18293                function sendBatch(idx, currentUploadId, lastTmpPath) {
18294                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
18295                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
18296                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
18297                  Promise.all(batches[idx].map(function (file) {
18298                    return fileToBase64(file).then(function (b64) {
18299                      return { path: file.webkitRelativePath || file.name, content: b64 };
18300                    });
18301                  })).then(function (fileList) {
18302                    var body = { files: fileList };
18303                    if (currentUploadId) body.upload_id = currentUploadId;
18304                    return fetch('/api/upload-directory', {
18305                      method: 'POST', headers: { 'Content-Type': 'application/json' },
18306                      body: JSON.stringify(body)
18307                    }).then(function (r) { return r.json(); });
18308                  }).then(function (d) {
18309                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
18310                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18311                  }).catch(function (e) {
18312                    showBannerToast('Upload failed: ' + String(e), true);
18313                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
18314                  });
18315                }
18316                sendBatch(0, null, '');
18317              }
18318            }
18319          };
18320          inputEl.click();
18321          return;
18322        }
18323
18324        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
18325        if (browseButton) browseButton.disabled = true;
18326
18327        if (previewPanel && targetInput === pathInput) {
18328          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
18329        }
18330
18331        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
18332          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
18333          .then(function (data) {
18334            if (data && data.selected_path) {
18335              targetInput.value = data.selected_path;
18336              scrollInputToEnd(targetInput);
18337
18338              if (targetInput === pathInput) {
18339                updateReportTitleFromPath();
18340                autoSetOutputDir(data.selected_path);
18341                fetchProjectHistory(data.selected_path);
18342                loadPreview();
18343                suggestCoverageFile(data.selected_path);
18344              }
18345
18346              updateReview();
18347            } else if (targetInput === pathInput) {
18348              loadPreview();
18349            }
18350          })
18351          .catch(function () {
18352            window.alert("Directory picker request failed.");
18353            if (previewPanel && targetInput === pathInput) {
18354              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
18355            }
18356          })
18357          .finally(function () {
18358            if (browseButton) browseButton.disabled = false;
18359          });
18360      }
18361
18362      if (themeToggle) {
18363        themeToggle.addEventListener("click", function () {
18364          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
18365          applyTheme(nextTheme);
18366          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
18367        });
18368      }
18369
18370      stepButtons.forEach(function (button) {
18371        button.addEventListener("click", function () {
18372          setStep(Number(button.getAttribute("data-step-target")));
18373        });
18374      });
18375
18376      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
18377        button.addEventListener("click", function () {
18378          setStep(Number(button.getAttribute("data-step-target")) || 1);
18379        });
18380      });
18381
18382      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
18383        button.addEventListener("click", function () {
18384          updateReview();
18385          setStep(Number(button.getAttribute("data-next")));
18386        });
18387      });
18388
18389      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
18390        button.addEventListener("click", function () {
18391          setStep(Number(button.getAttribute("data-prev")));
18392        });
18393      });
18394
18395      document.addEventListener("keydown", function (e) {
18396        var tag = (document.activeElement || {}).tagName || "";
18397        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
18398        if (e.altKey || e.ctrlKey || e.metaKey) return;
18399        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
18400        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
18401      });
18402
18403      if (useSamplePath) {
18404        useSamplePath.addEventListener("click", function () {
18405          pathInput.value = "tests/fixtures/basic";
18406          updateReportTitleFromPath();
18407          autoSetOutputDir("tests/fixtures/basic");
18408          loadPreview();
18409          suggestCoverageFile("tests/fixtures/basic");
18410        });
18411      }
18412
18413      if (useDefaultOutput) {
18414        useDefaultOutput.addEventListener("click", function () {
18415          delete outputDirInput.dataset.userEdited;
18416          autoSetOutputDir(pathInput ? pathInput.value : "");
18417          updateReview();
18418        });
18419      }
18420
18421      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
18422      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
18423
18424      // ── Drag-and-drop directory upload (server mode only) ─────────────────
18425      // Dropping a folder onto the path field bypasses Chrome's
18426      // "Upload X files to this site?" confirmation dialog.
18427      async function readDirRecursively(dirEntry, basePath) {
18428        var reader = dirEntry.createReader();
18429        var all = [];
18430        for (;;) {
18431          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
18432          if (!batch.length) break;
18433          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
18434        }
18435        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
18436        var out = [];
18437        for (var i = 0; i < all.length; i++) {
18438          var sub = all[i];
18439          if (sub.isFile) {
18440            var f = await new Promise(function(res) { sub.file(res); });
18441            out.push({ file: f, path: basePath + '/' + sub.name });
18442          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
18443            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
18444            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
18445          }
18446        }
18447        return out;
18448      }
18449
18450      function setupPathDropZone() {
18451        if (!SERVER_MODE || !pathInput) return;
18452        var CODE_EXTS = new Set([
18453          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18454          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18455          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18456          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18457          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18458          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
18459        ]);
18460        pathInput.addEventListener('dragover', function(e) {
18461          e.preventDefault();
18462          pathInput.classList.add('drag-over');
18463        });
18464        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
18465        pathInput.addEventListener('drop', function(e) {
18466          e.preventDefault();
18467          pathInput.classList.remove('drag-over');
18468          var items = e.dataTransfer.items;
18469          if (!items || !items.length) return;
18470          var dirEntry = null;
18471          for (var i = 0; i < items.length; i++) {
18472            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
18473            if (entry && entry.isDirectory) { dirEntry = entry; break; }
18474          }
18475          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
18476          var btn = browsePath;
18477          if (btn) btn.disabled = true;
18478          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
18479
18480          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
18481            var total = allEntries.length;
18482            var codeEntries = allEntries.filter(function(e) {
18483              var n = e.file.name;
18484              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
18485              var dot = n.lastIndexOf('.');
18486              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
18487            });
18488            var kept = codeEntries.length;
18489            if (kept === 0) {
18490              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
18491              if (btn) btn.disabled = false; return;
18492            }
18493
18494            function finish(tmpPath, sizes) {
18495              pathInput.value = tmpPath;
18496              scrollInputToEnd(pathInput);
18497              if (sizes) {
18498                window._lastUploadSizes = sizes;
18499                var sizeText = document.getElementById('project-size-text');
18500                var sizeBtn = document.getElementById('project-size-btn');
18501                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18502                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18503                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18504                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18505              }
18506              updateReportTitleFromPath();
18507              autoSetOutputDir(tmpPath);
18508              fetchProjectHistory(tmpPath);
18509              loadPreview();
18510              suggestCoverageFile(tmpPath);
18511              updateReview();
18512              if (btn) btn.disabled = false;
18513            }
18514
18515            if (typeof CompressionStream === 'undefined') {
18516              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
18517              if (btn) btn.disabled = false; return;
18518            }
18519
18520            try {
18521              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18522              var BLOCK = 512;
18523              var cs = new CompressionStream('gzip');
18524              var wtr = cs.writable.getWriter();
18525              var chunks = [];
18526              var rdr = cs.readable.getReader();
18527              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
18528
18529              function buildHdr(fp, sz) {
18530                var hdr = new Uint8Array(BLOCK);
18531                var enc = new TextEncoder();
18532                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]; }
18533                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
18534                var nm = fp, pfx = '';
18535                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); } }
18536                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
18537                for (var i = 148; i < 156; i++) hdr[i] = 32;
18538                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);
18539                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18540                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
18541                return hdr;
18542              }
18543
18544              for (var i = 0; i < codeEntries.length; i++) {
18545                var ce = codeEntries[i];
18546                var buf = await ce.file.arrayBuffer();
18547                var data = new Uint8Array(buf);
18548                await wtr.write(buildHdr(ce.path, data.length));
18549                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
18550                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
18551                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18552              }
18553              await wtr.write(new Uint8Array(BLOCK * 2));
18554              await wtr.close();
18555              await collecting;
18556
18557              var blob = new Blob(chunks, { type: 'application/gzip' });
18558              var sizeMB = (blob.size / 1048576).toFixed(1);
18559              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
18560              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
18561              var d = await resp.json();
18562              if (d && d.tmp_path) {
18563                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
18564              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
18565            } catch (err) {
18566              showBannerToast('Upload failed: ' + String(err), true);
18567              if (btn) btn.disabled = false;
18568            }
18569          }).catch(function(err) {
18570            showBannerToast('Could not read folder: ' + String(err), true);
18571            if (btn) btn.disabled = false;
18572          });
18573        });
18574      }
18575      setupPathDropZone();
18576      if (browseCoverage) {
18577        browseCoverage.addEventListener("click", function () {
18578          pickDirectory(coverageInput || pathInput, "coverage");
18579        });
18580      }
18581
18582      function setCovStatus(state, opts) {
18583        if (!covScanStatus) return;
18584        opts = opts || {};
18585        covScanStatus.className = "cov-scan-status cov-scan-" + state;
18586        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
18587        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>';
18588        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>';
18589        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>';
18590        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>';
18591        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
18592        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
18593        if (state === "scanning") {
18594          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
18595        } else if (state === "found") {
18596          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18597          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
18598          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
18599          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
18600        } else if (state === "hint") {
18601          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18602          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
18603          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</div>';
18604        } else if (state === "none") {
18605          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
18606          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
18607        }
18608        html += '</div></div>';
18609        covScanStatus.innerHTML = html;
18610        if (state === "found") {
18611          var useBtn = covScanStatus.querySelector(".cov-scan-use");
18612          if (useBtn) useBtn.addEventListener("click", function () {
18613            if (coverageInput) coverageInput.value = "";
18614            covAutoFilled = false;
18615            setCovStatus("idle");
18616          });
18617        }
18618      }
18619
18620      function suggestCoverageFile(projectPath) {
18621        if (!coverageInput || !covScanStatus) return;
18622        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18623        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
18624        clearTimeout(coverageSuggestTimer);
18625        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
18626        setCovStatus("scanning");
18627        coverageSuggestTimer = setTimeout(function () {
18628          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
18629            .then(function (r) { return r.json(); })
18630            .then(function (d) {
18631              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18632              if (!d) { setCovStatus("none"); return; }
18633              if (d.found) {
18634                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
18635                setCovStatus("found", { found: d.found, tool: d.tool });
18636              } else if (d.tool && d.hint) {
18637                setCovStatus("hint", { tool: d.tool, hint: d.hint });
18638              } else {
18639                setCovStatus("none");
18640              }
18641            })
18642            .catch(function () { setCovStatus("idle"); });
18643        }, 600);
18644      }
18645
18646      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
18647
18648      if (coverageInput) coverageInput.addEventListener("input", function () {
18649        covAutoFilled = false;
18650        if (!this.value.trim()) setCovStatus("idle");
18651      });
18652
18653      // ── Language pill overflow: collapse to "+N more" chip ─────────────
18654      function collapseLanguagePills() {
18655        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
18656        rows.forEach(function(row) {
18657          // Remove any previous overflow chip
18658          var prev = row.querySelector('.lang-overflow-chip');
18659          if (prev) prev.remove();
18660          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
18661          pills.forEach(function(p) { p.style.display = ''; });
18662          if (!pills.length) return;
18663
18664          // Measure after restoring all pills
18665          var containerRight = row.getBoundingClientRect().right;
18666          var hidden = [];
18667          for (var i = pills.length - 1; i >= 1; i--) {
18668            var rect = pills[i].getBoundingClientRect();
18669            if (rect.right > containerRight + 2) {
18670              hidden.unshift(pills[i]);
18671              pills[i].style.display = 'none';
18672            } else {
18673              break;
18674            }
18675          }
18676
18677          if (hidden.length) {
18678            var chip = document.createElement('button');
18679            chip.type = 'button';
18680            chip.className = 'language-pill lang-overflow-chip';
18681            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
18682            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
18683            row.appendChild(chip);
18684          }
18685        });
18686      }
18687
18688      // Run after preview loads (preview panel populates language pills)
18689      var _origLoadPreviewCb = window.__previewLoaded;
18690      document.addEventListener('previewLoaded', collapseLanguagePills);
18691      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
18692      setTimeout(collapseLanguagePills, 400);
18693
18694      // ── Project history & output dir auto-set ──────────────────────────
18695      var wsOutputRoot   = document.getElementById("ws-output-root");
18696      var wsScanCount    = document.getElementById("ws-scan-count");
18697      var wsLastScan     = document.getElementById("ws-last-scan");
18698      var historyBadge   = document.getElementById("path-history-badge");
18699      var historyTimer   = null;
18700
18701      var wsOutputLink = document.getElementById("ws-output-link");
18702      function syncStripOutputRoot() {
18703        var val = outputDirInput ? outputDirInput.value : "";
18704        var display = val || "project/sloc";
18705        if (wsOutputRoot) wsOutputRoot.textContent = display;
18706        if (wsOutputLink) wsOutputLink.dataset.folder = val;
18707      }
18708
18709      function scrollInputToEnd(input) {
18710        if (!input) return;
18711        // Defer so the DOM has the new value before we measure scroll width.
18712        requestAnimationFrame(function () {
18713          input.scrollLeft = input.scrollWidth;
18714          input.selectionStart = input.selectionEnd = input.value.length;
18715        });
18716      }
18717
18718      function autoSetOutputDir(projectPath) {
18719        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
18720        if (GIT_MODE && GIT_OUTPUT_DIR) {
18721          outputDirInput.value = GIT_OUTPUT_DIR;
18722          scrollInputToEnd(outputDirInput);
18723          syncStripOutputRoot();
18724          updateReview();
18725          return;
18726        }
18727        if (!projectPath || !projectPath.trim()) return;
18728        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
18729        outputDirInput.value = cleaned + "/sloc";
18730        scrollInputToEnd(outputDirInput);
18731        syncStripOutputRoot();
18732        updateReview();
18733      }
18734
18735      var wsBranch = document.getElementById("ws-branch");
18736
18737      function fetchProjectHistory(projectPath) {
18738        if (!projectPath || !projectPath.trim()) {
18739          if (wsScanCount) wsScanCount.textContent = "—";
18740          if (wsLastScan)  wsLastScan.textContent  = "—";
18741          if (wsBranch)    wsBranch.textContent    = "—";
18742          if (historyBadge) historyBadge.style.display = "none";
18743          return;
18744        }
18745        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
18746          .then(function (r) { return r.ok ? r.json() : null; })
18747          .then(function (data) {
18748            if (!data) return;
18749            var countStr = data.scan_count > 0
18750              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
18751              : "never";
18752            var tsStr = data.last_scan_timestamp
18753              ? data.last_scan_timestamp.replace(" UTC","")
18754              : "—";
18755            if (wsScanCount) wsScanCount.textContent = countStr;
18756            if (wsLastScan)  wsLastScan.textContent  = tsStr;
18757            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
18758            if (data.scan_count > 0) {
18759              if (historyBadge) {
18760                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
18761                historyBadge.textContent = data.scan_count + " previous scan" +
18762                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
18763                  "Last: " + (data.last_scan_timestamp || "—") +
18764                  " — " + (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.";
18765                historyBadge.className = "path-history-badge found";
18766                historyBadge.style.display = "";
18767              }
18768            } else {
18769              if (historyBadge) historyBadge.style.display = "none";
18770            }
18771          })
18772          .catch(function () {});
18773      }
18774
18775      function onPathChange() {
18776        var val = pathInput ? pathInput.value : "";
18777        // Discard stale upload sizes when the user edits the path manually.
18778        window._lastUploadSizes = null;
18779        updateReportTitleFromPath();
18780        autoSetOutputDir(val);
18781        updateSidebarSummary();
18782        clearTimeout(historyTimer);
18783        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
18784        if (previewTimer) clearTimeout(previewTimer);
18785        previewTimer = setTimeout(loadPreview, 280);
18786        suggestCoverageFile(val);
18787      }
18788
18789      if (pathInput) {
18790        pathInput.addEventListener("input", onPathChange);
18791      }
18792
18793      if (outputDirInput) {
18794        outputDirInput.addEventListener("input", function () {
18795          outputDirInput.dataset.userEdited = "1";
18796          syncStripOutputRoot();
18797          updateReview();
18798        });
18799      }
18800
18801      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
18802        if (!node) return;
18803        node.addEventListener("input", function () {
18804          updateReview();
18805          if (previewTimer) clearTimeout(previewTimer);
18806          previewTimer = setTimeout(loadPreview, 280);
18807        });
18808      });
18809
18810      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
18811        var node = document.getElementById(id);
18812        if (node) node.addEventListener("change", updateReview);
18813      });
18814
18815      if (reportTitleInput) {
18816        reportTitleInput.addEventListener("input", function () {
18817          reportTitleTouched = reportTitleInput.value.trim().length > 0;
18818          updateReportTitleFromPath();
18819          updateReview();
18820        });
18821      }
18822
18823      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
18824      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
18825      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
18826      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
18827
18828      if (coverageInput) {
18829        coverageInput.addEventListener("input", function () {
18830          if (coverageInput.value.trim()) setCovStatus("idle");
18831        });
18832      }
18833
18834      if (form && loading && submitButton) {
18835        form.addEventListener("submit", function (e) {
18836          e.preventDefault();
18837          submitButton.disabled = true;
18838          submitButton.textContent = "Scanning...";
18839          startAsyncAnalysis(new FormData(form));
18840        });
18841      }
18842
18843      function openPath(folder) {
18844        if (!folder) return;
18845        fetch('/open-path?path=' + encodeURIComponent(folder))
18846          .then(function (r) { return r.json(); })
18847          .then(function (d) {
18848            if (d && d.server_mode_disabled)
18849              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18850          })
18851          .catch(function () {});
18852      }
18853
18854      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18855        btn.addEventListener('click', function () {
18856          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
18857        });
18858      });
18859
18860      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
18861      if (wsOutputLink) {
18862        wsOutputLink.addEventListener('click', function () {
18863          openPath(wsOutputLink.dataset.folder || '');
18864        });
18865      }
18866
18867      loadSavedTheme();
18868      updateMixedPolicyUI();
18869      updatePythonDocstringUI();
18870      applyScanPreset();
18871      updatePresetDescriptions();
18872      applyArtifactPreset();
18873      updateReview();
18874      updateScrollProgress(); // initialise bar to 0% (step 1)
18875      window.addEventListener("scroll", updateScrollProgress, { passive: true });
18876      onPathChange();         // seed output dir, history badge, and preview from initial path
18877      updateStepNav(1);
18878
18879      // Restore step from URL hash on initial load (e.g., back-forward cache)
18880      (function() {
18881        var hashMatch = location.hash.match(/^#step([1-4])$/);
18882        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
18883      })();
18884
18885      (function randomizeWatermarks() {
18886        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
18887        if (!wms.length) return;
18888        var placed = [];
18889        function tooClose(top, left) {
18890          for (var i = 0; i < placed.length; i++) {
18891            var dt = Math.abs(placed[i][0] - top);
18892            var dl = Math.abs(placed[i][1] - left);
18893            if (dt < 16 && dl < 12) return true;
18894          }
18895          return false;
18896        }
18897        function pick(leftBand) {
18898          for (var attempt = 0; attempt < 50; attempt++) {
18899            var top = Math.random() * 88 + 2;
18900            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18901            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18902          }
18903          var top = Math.random() * 88 + 2;
18904          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18905          placed.push([top, left]);
18906          return [top, left];
18907        }
18908        var half = Math.floor(wms.length / 2);
18909        wms.forEach(function (img, i) {
18910          var pos = pick(i < half);
18911          var size = Math.floor(Math.random() * 80 + 110);
18912          var rot = (Math.random() * 360).toFixed(1);
18913          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
18914          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;
18915        });
18916      })();
18917
18918      (function spawnCodeParticles() {
18919        var container = document.getElementById('code-particles');
18920        if (!container) return;
18921        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'];
18922        for (var i = 0; i < 38; i++) {
18923          (function(idx) {
18924            var el = document.createElement('span');
18925            el.className = 'code-particle';
18926            el.textContent = snippets[idx % snippets.length];
18927            var left = Math.random() * 94 + 2;
18928            var top = Math.random() * 88 + 6;
18929            var dur = (Math.random() * 10 + 9).toFixed(1);
18930            var delay = (Math.random() * 18).toFixed(1);
18931            var rot = (Math.random() * 26 - 13).toFixed(1);
18932            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18933            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';
18934            container.appendChild(el);
18935          })(i);
18936        }
18937      })();
18938    })();
18939  </script>
18940  <script nonce="{{ csp_nonce }}">
18941    (function () {
18942      var raw = {{ prefill_json|safe }};
18943      if (!raw || typeof raw !== 'object' || !raw.path) return;
18944      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
18945      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
18946      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
18947      setVal('path', raw.path || '');
18948      setVal('include_globs', raw.include_globs || '');
18949      setVal('exclude_globs', raw.exclude_globs || '');
18950      setVal('output_dir', raw.output_dir || '');
18951      setVal('report_title', raw.report_title || '');
18952      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
18953      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
18954      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
18955      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
18956      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
18957      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
18958      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
18959      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
18960      setChecked('generate_html', raw.generate_html !== false);
18961      setChecked('generate_pdf', !!raw.generate_pdf);
18962      // Trigger dynamic UI updates after pre-fill.
18963      setTimeout(function () {
18964        var pathEl = document.getElementById('path');
18965        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
18966        var policyEl = document.getElementById('mixed_line_policy');
18967        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
18968      }, 80);
18969    })();
18970  </script>
18971  <script nonce="{{ csp_nonce }}">
18972  (function(){
18973    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'}];
18974    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);});}
18975    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18976    function init(){
18977      var btn=document.getElementById('settings-btn');if(!btn)return;
18978      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18979      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>';
18980      document.body.appendChild(m);
18981      var g=document.getElementById('scheme-grid');
18982      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);});
18983      var cl=document.getElementById('settings-close');
18984      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);
18985      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');});
18986      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18987      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18988    }
18989    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18990  }());
18991  </script>
18992  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
18993    <div class="wb-ftip-arrow"></div>
18994    <span id="wb-ftip-text"></span>
18995  </div>
18996  <script nonce="{{ csp_nonce }}">(function(){
18997    var tip=document.getElementById('wb-ftip');
18998    var txt=document.getElementById('wb-ftip-text');
18999    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
19000    if(!tip||!txt)return;
19001    function pos(el){
19002      var r=el.getBoundingClientRect();
19003      tip.style.display='block';
19004      var tw=tip.offsetWidth;
19005      var lx=r.left+r.width/2-tw/2;
19006      if(lx<8)lx=8;
19007      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
19008      tip.style.left=lx+'px';
19009      tip.style.top=(r.bottom+8)+'px';
19010      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';}
19011    }
19012    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
19013      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
19014      el.addEventListener('mouseleave',function(){tip.style.display='none';});
19015    });
19016    window.addEventListener('blur',function(){tip.style.display='none';});
19017    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
19018  })();
19019  (function(){
19020    function fixArtifactHintSpacing(){
19021      var grid=document.querySelector('.artifact-grid');
19022      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
19023    }
19024    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
19025  }());
19026  (function(){
19027    var dot=document.getElementById('status-dot');
19028    var pingEl=document.getElementById('server-ping-ms');
19029    var tipEl=document.getElementById('server-tip-ping');
19030    var fm=document.getElementById('footer-mode');
19031    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)';}}
19032    function doPing(){
19033      var t0=performance.now();
19034      fetch('/healthz',{cache:'no-store'})
19035        .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);})
19036        .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)';}});
19037    }
19038    doPing();
19039    setInterval(doPing,5000);
19040    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
19041  })();
19042  </script>
19043  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
19044  <footer class="site-footer">
19045    local code analysis - metrics, history and reports
19046    &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>
19047    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19048    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19049    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19050    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19051  </footer>
19052</body>
19053</html>
19054"##,
19055    ext = "html"
19056)]
19057struct IndexTemplate {
19058    version: &'static str,
19059    prefill_json: String,
19060    csp_nonce: String,
19061    git_repo: String,
19062    git_ref: String,
19063    git_label_json: String,
19064    git_output_dir_json: String,
19065    server_mode: bool,
19066}
19067
19068// ── SplashTemplate ────────────────────────────────────────────────────────────
19069
19070#[derive(Template)]
19071#[template(
19072    source = r##"
19073<!doctype html>
19074<html lang="en">
19075<head>
19076  <meta charset="utf-8">
19077  <meta name="viewport" content="width=device-width, initial-scale=1">
19078  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
19079  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19080  <script type="application/ld+json">
19081  {
19082    "@context": "https://schema.org",
19083    "@type": "SoftwareApplication",
19084    "name": "oxide-sloc",
19085    "applicationCategory": "DeveloperApplication",
19086    "operatingSystem": "Windows, Linux",
19087    "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.",
19088    "softwareVersion": "{{ version }}",
19089    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
19090    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
19091    "url": "https://github.com/oxide-sloc/oxide-sloc",
19092    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
19093    "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",
19094    "programmingLanguage": "Rust",
19095    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
19096  }
19097  </script>
19098  <style nonce="{{ csp_nonce }}">
19099    :root {
19100      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19101      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19102      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19103      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19104      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19105    }
19106    body.dark-theme {
19107      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19108      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19109    }
19110    *{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;}
19111    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19112    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19113    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19114    .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;}
19115    @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));}}
19116    .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);}
19117    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19118    .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));}
19119    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19120    .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;}
19121    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19122    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19123    @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; } }
19124    .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;}
19125    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19126    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19127    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19128    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19129    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19130    .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;}
19131    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19132    .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);}
19133    .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;}
19134    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19135    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19136    .settings-modal-body{padding:14px 16px 16px;}
19137    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19138    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19139    .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;}
19140    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19141    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19142    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19143    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19144    .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;}
19145    .tz-select:focus{border-color:var(--oxide);}
19146    .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;}
19147    .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;}
19148    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
19149    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
19150    .hero{text-align:center;margin:0 auto 18px;}
19151    .hero-logo-wrap{display:inline-block;cursor:default;}
19152    .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;}
19153    .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;}
19154    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
19155    .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;}
19156    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%);}
19157    .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;
19158      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
19159      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
19160      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;}
19161    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
19162    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
19163    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;}
19164    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
19165    .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;}
19166    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
19167    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
19168    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
19169    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
19170    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
19171    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
19172    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
19173    .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;}
19174    .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;}
19175    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19176    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
19177    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19178    .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);}
19179    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
19180    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
19181    .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);}
19182    .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);}
19183    .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);}
19184    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
19185    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
19186    .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;}
19187    body.dark-theme .action-card-cta{color:var(--oxide);}
19188    .action-card.view .action-card-cta{color:var(--accent-2);}
19189    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
19190    .action-card.compare .action-card-cta{color:#7c3aed;}
19191    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
19192    .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);}
19193    .action-card.git-tools .action-card-cta{color:#15803d;}
19194    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
19195    .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);}
19196    .action-card.trend .action-card-cta{color:#0e7490;}
19197    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
19198    .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);}
19199    .action-card.automation .action-card-cta{color:#b45309;}
19200    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
19201    .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);}
19202    .action-card.test-metrics .action-card-cta{color:#be185d;}
19203    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
19204    .action-card:hover .action-card-cta{gap:12px;}
19205    .action-card.card-split{flex-direction:row;align-items:stretch;}
19206    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
19207    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
19208    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
19209    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
19210    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
19211    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
19212    .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;}
19213    .ac-badge.active{opacity:1;}
19214    .ac-badge.github{border-color:#555;color:#555;}
19215    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
19216    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
19217    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
19218    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
19219    body.dark-theme .ac-right-row{color:var(--muted);}
19220    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
19221    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
19222    .divider{height:1px;background:var(--line);margin:32px 0;}
19223    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
19224    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
19225    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
19226    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
19227      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
19228    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19229    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
19230    body.dark-theme .info-chip-val{color:var(--oxide);}
19231    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
19232    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
19233      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
19234      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
19235    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
19236      border:6px solid transparent;border-top-color:var(--text);}
19237    .info-chip:hover .info-chip-tip{display:block;}
19238    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
19239    .chip-slide.fading{filter:blur(5px);opacity:0;}
19240    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19241    .site-footer a{color:var(--muted);}
19242    .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;}
19243    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
19244    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
19245    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
19246    .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;}
19247    .lan-badge.local{background:var(--oxide-2);}
19248    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
19249    .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);}
19250    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
19251    .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;}
19252    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
19253    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
19254    .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;}
19255    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
19256    .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;}
19257    .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);}
19258    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
19259    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
19260    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
19261    .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;}
19262    @media (max-height: 1100px) {
19263      .page{padding-top:10px;}
19264      .hero{margin-bottom:10px;}
19265      .hero-logo{width:54px;height:60px;}
19266      .hero-logo-shadow{width:42px;}
19267      .hero-title{font-size:28px;}
19268      .hero-subtitle{font-size:13px;}
19269      .card-sections{gap:12px;margin-bottom:6px;}
19270      .card-section-grid-2,.card-section-grid-3{gap:10px;}
19271      .action-card{padding:8px 15px 8px;}
19272      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
19273      .action-card-icon svg{width:18px;height:18px;}
19274      .action-card-title{font-size:13px;}
19275      .action-card-desc{font-size:11px;margin-bottom:6px;}
19276      .action-card-cta{font-size:11px;}
19277      .ac-right-row{font-size:11px;}
19278      .divider{margin:14px 0;}
19279      .info-strip{gap:7px;margin-bottom:8px;}
19280      .info-chip{padding:7px 10px;}
19281      .info-chip-val{font-size:13px;}
19282      .info-chip-label{font-size:9px;}
19283      .site-footer{padding:8px 24px;font-size:12px;}
19284      .lan-local-hint{margin-top:8px;}
19285    }
19286    @media (max-height: 850px) {
19287      .page{padding-top:6px;}
19288      .hero{margin-bottom:6px;}
19289      .hero-logo{width:42px;height:46px;}
19290      .hero-title{font-size:22px;}
19291      .hero-subtitle{font-size:12px;}
19292      .card-sections{gap:10px;}
19293      .action-card-desc{margin-bottom:4px;}
19294      .divider{margin:8px 0;}
19295      .info-strip{margin-bottom:6px;}
19296      .lan-local-hint{margin-top:10px;}
19297    }
19298  </style>
19299</head>
19300<body>
19301  <div class="background-watermarks" aria-hidden="true">
19302    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19303    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19304    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19305    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19306    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19307    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19308    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19309  </div>
19310  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19311  <div class="top-nav">
19312    <div class="top-nav-inner">
19313      <a class="brand" href="/">
19314        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19315        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19316      </a>
19317      <div class="nav-right">
19318        <a class="nav-pill" href="/">Home</a>
19319        <div class="nav-dropdown">
19320          <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>
19321          <div class="nav-dropdown-menu">
19322            <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>
19323          </div>
19324        </div>
19325        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19326        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19327        <div class="nav-dropdown">
19328          <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>
19329          <div class="nav-dropdown-menu">
19330            <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>
19331          </div>
19332        </div>
19333        <div class="server-status-wrap" id="server-status-wrap">
19334          <div class="nav-pill server-online-pill" id="server-status-pill">
19335            <span class="status-dot" id="status-dot"></span>
19336            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19337            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19338          </div>
19339          <div class="server-status-tip">
19340            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
19341            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19342          </div>
19343        </div>
19344        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19345          <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>
19346        </button>
19347        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19348          <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>
19349          <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>
19350        </button>
19351      </div>
19352    </div>
19353  </div>
19354
19355  <div class="page">
19356    <div class="hero">
19357      <div class="hero-logo-wrap" id="hero-logo-wrap">
19358        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
19359      </div>
19360      <div class="hero-logo-shadow"></div>
19361      <div class="hero-title-wrap">
19362        <div class="hero-title-aura" aria-hidden="true"></div>
19363        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
19364      </div>
19365      <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>
19366    </div>
19367
19368    <div class="card-sections">
19369
19370      <div>
19371        <div class="card-section-label">Analysis</div>
19372        <div class="card-section-grid-2">
19373          <a class="action-card scan card-split" href="/scan-setup">
19374            <div class="action-card-left">
19375              <div class="action-card-icon">
19376                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19377              </div>
19378              <div class="action-card-title">Scan Project</div>
19379              <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>
19380              <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>
19381            </div>
19382            <div class="action-card-sep"></div>
19383            <div class="action-card-right">
19384              <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>
19385              <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>
19386              <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>
19387              <div class="ac-right-stat" id="acp-scan-stat"></div>
19388            </div>
19389          </a>
19390          <a class="action-card test-metrics card-split" href="/test-metrics">
19391            <div class="action-card-left">
19392              <div class="action-card-icon">
19393                <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>
19394              </div>
19395              <div class="action-card-title">Test Metrics</div>
19396              <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>
19397              <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>
19398            </div>
19399            <div class="action-card-sep"></div>
19400            <div class="action-card-right">
19401              <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>
19402              <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>
19403              <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>
19404              <div class="ac-right-stat" id="acp-test-stat"></div>
19405            </div>
19406          </a>
19407        </div>
19408      </div>
19409
19410      <div>
19411        <div class="card-section-label">Reports &amp; Insights</div>
19412        <div class="card-section-grid-3">
19413          <a class="action-card view" href="/view-reports">
19414            <div class="action-card-icon">
19415              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
19416            </div>
19417            <div class="action-card-title">View Reports</div>
19418            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
19419            <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>
19420          </a>
19421          <a class="action-card compare" href="/compare-scans">
19422            <div class="action-card-icon">
19423              <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>
19424            </div>
19425            <div class="action-card-title">Compare Scans</div>
19426            <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>
19427            <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>
19428          </a>
19429          <a class="action-card trend" href="/trend-reports">
19430            <div class="action-card-icon">
19431              <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>
19432            </div>
19433            <div class="action-card-title">Trend Report</div>
19434            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
19435            <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>
19436          </a>
19437        </div>
19438      </div>
19439
19440      <div>
19441        <div class="card-section-label">Developer Tools</div>
19442        <div class="card-section-grid-2">
19443          <a class="action-card git-tools card-split" href="/git-browser">
19444            <div class="action-card-left">
19445              <div class="action-card-icon">
19446                <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>
19447              </div>
19448              <div class="action-card-title">Git Browser</div>
19449              <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>
19450              <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>
19451            </div>
19452            <div class="action-card-sep"></div>
19453            <div class="action-card-right">
19454              <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>
19455              <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>
19456              <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>
19457            </div>
19458          </a>
19459          <a class="action-card automation card-split" href="/integrations">
19460            <div class="action-card-left">
19461              <div class="action-card-icon">
19462                <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>
19463              </div>
19464              <div class="action-card-title">Integrations</div>
19465              <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>
19466              <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>
19467            </div>
19468            <div class="action-card-sep"></div>
19469            <div class="action-card-right">
19470              <div class="ac-badges-grid">
19471                <span class="ac-badge github"     id="acp-gh">GitHub</span>
19472                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
19473                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
19474                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
19475              </div>
19476              <div class="ac-right-stat" id="acp-int-stat"></div>
19477            </div>
19478          </a>
19479        </div>
19480      </div>
19481
19482    </div>
19483
19484    {% if server_mode %}
19485    <div class="lan-card server">
19486      <div class="lan-card-header">
19487        <span class="lan-badge">LAN server</span>
19488        Accessible on your network
19489      </div>
19490      {% if let Some(ip) = lan_ip %}
19491      <div class="lan-url-row">
19492        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
19493        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
19494          <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>
19495          Copy URL
19496        </button>
19497      </div>
19498      <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>
19499      {% if has_api_key %}
19500      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
19501      {% endif %}
19502      {% else %}
19503      <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>
19504      {% endif %}
19505    </div>
19506    {% endif %}
19507
19508    <div class="divider"></div>
19509
19510    <div class="info-strip">
19511      <div class="info-chip">
19512        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
19513        <div class="chip-slide">
19514          <div class="info-chip-val">60</div>
19515          <div class="info-chip-label">Languages</div>
19516        </div>
19517      </div>
19518      <div class="info-chip">
19519        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
19520        <div class="chip-slide">
19521          <div class="info-chip-val">100%</div>
19522          <div class="info-chip-label">Self-contained</div>
19523        </div>
19524      </div>
19525      <div class="info-chip">
19526        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
19527        <div class="chip-slide">
19528          <div class="info-chip-val">HTML+PDF</div>
19529          <div class="info-chip-label">Exportable reports</div>
19530        </div>
19531      </div>
19532      <div class="info-chip">
19533        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
19534        <div class="chip-slide">
19535          <div class="info-chip-val">Webhook</div>
19536          <div class="info-chip-label">3 platforms</div>
19537        </div>
19538      </div>
19539      <div class="info-chip">
19540        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
19541        <div class="chip-slide">
19542          <div class="info-chip-val">IEEE</div>
19543          <div class="info-chip-label">1045-1992</div>
19544        </div>
19545      </div>
19546    </div>
19547
19548    {% if lan_ip.is_none() %}
19549    <div class="lan-local-hint">
19550      <strong>Want teammates on the same network to access this?</strong><br>
19551      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
19552    </div>
19553    {% endif %}
19554  </div>
19555
19556  <footer class="site-footer">
19557    local code analysis - metrics, history and reports
19558    &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>
19559    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19560    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19561    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19562    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19563  </footer>
19564
19565  <script nonce="{{ csp_nonce }}">
19566    (function () {
19567      var storageKey = 'oxide-sloc-theme';
19568      var body = document.body;
19569      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19570      var toggle = document.getElementById('theme-toggle');
19571      if (toggle) toggle.addEventListener('click', function () {
19572        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19573        body.classList.toggle('dark-theme', next === 'dark');
19574        try { localStorage.setItem(storageKey, next); } catch(e) {}
19575      });
19576      var copyBtn = document.getElementById('lan-copy-btn');
19577      if (copyBtn) copyBtn.addEventListener('click', function() {
19578        var btn = this;
19579        var el = document.getElementById('lan-url-val');
19580        if (!el) return;
19581        var url = el.textContent.trim();
19582        if (navigator.clipboard) {
19583          navigator.clipboard.writeText(url).then(function() {
19584            var orig = btn.innerHTML;
19585            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!';
19586            setTimeout(function() { btn.innerHTML = orig; }, 1800);
19587          });
19588        }
19589      });
19590      (function randomizeWatermarks() {
19591        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19592        if (!wms.length) return;
19593        var placed = [];
19594        function tooClose(top, left) {
19595          for (var i = 0; i < placed.length; i++) {
19596            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19597            if (dt < 16 && dl < 12) return true;
19598          }
19599          return false;
19600        }
19601        function pick(leftBand) {
19602          for (var attempt = 0; attempt < 50; attempt++) {
19603            var top = Math.random() * 88 + 2;
19604            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19605            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19606          }
19607          var top = Math.random() * 88 + 2;
19608          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19609          placed.push([top, left]); return [top, left];
19610        }
19611        var half = Math.floor(wms.length / 2);
19612        wms.forEach(function (img, i) {
19613          var pos = pick(i < half);
19614          var size = Math.floor(Math.random() * 100 + 120);
19615          var rot = (Math.random() * 360).toFixed(1);
19616          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19617          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;
19618        });
19619      })();
19620
19621      (function spawnCodeParticles() {
19622        var container = document.getElementById('code-particles');
19623        if (!container) return;
19624        var snippets = [
19625          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19626          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19627          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19628          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19629          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19630        ];
19631        var count = 38;
19632        for (var i = 0; i < count; i++) {
19633          (function(idx) {
19634            var el = document.createElement('span');
19635            el.className = 'code-particle';
19636            var text = snippets[idx % snippets.length];
19637            el.textContent = text;
19638            var left = Math.random() * 94 + 2;
19639            var top = Math.random() * 88 + 6;
19640            var dur = (Math.random() * 10 + 9).toFixed(1);
19641            var delay = (Math.random() * 18).toFixed(1);
19642            var rot = (Math.random() * 26 - 13).toFixed(1);
19643            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19644            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
19645              + '--rot:' + rot + 'deg;--op:' + op + ';'
19646              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
19647            container.appendChild(el);
19648          })(i);
19649        }
19650      })();
19651      (function heroAnimations() {
19652        var sub = document.getElementById('hero-subtitle');
19653        if (sub) {
19654          var full = sub.textContent.trim();
19655          sub.textContent = '';
19656          sub.style.opacity = '1';
19657          var cursor = document.createElement('span');
19658          cursor.className = 'hero-cursor';
19659          sub.appendChild(cursor);
19660          var i = 0;
19661          setTimeout(function() {
19662            var iv = setInterval(function() {
19663              if (i < full.length) {
19664                sub.insertBefore(document.createTextNode(full[i]), cursor);
19665                i++;
19666              } else {
19667                clearInterval(iv);
19668                setTimeout(function() {
19669                  cursor.style.transition = 'opacity 1s ease';
19670                  cursor.style.opacity = '0';
19671                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
19672                }, 2400);
19673              }
19674            }, 11);
19675          }, 374);
19676        }
19677      })();
19678      (function logoBob() {
19679        var logo = document.querySelector('.hero-logo');
19680        var shadow = document.querySelector('.hero-logo-shadow');
19681        if (!logo) return;
19682        var cycleStart = null, cycleDur = 3600;
19683        var peakY = -14, peakScale = 1.07, peakRot = 0;
19684        function newCycle() {
19685          cycleDur = 3000 + Math.random() * 1840;
19686          peakY = -(9 + Math.random() * 13.8);
19687          peakScale = 1.04 + Math.random() * 0.081;
19688          peakRot = (Math.random() * 11.5 - 5.75);
19689        }
19690        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
19691        newCycle();
19692        function frame(ts) {
19693          if (cycleStart === null) cycleStart = ts;
19694          var t = (ts - cycleStart) / cycleDur;
19695          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
19696          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
19697          var y = peakY * phase;
19698          var sc = 1 + (peakScale - 1) * phase;
19699          var rot = peakRot * Math.sin(Math.PI * phase);
19700          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
19701          if (shadow) {
19702            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
19703            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
19704          }
19705          requestAnimationFrame(frame);
19706        }
19707        requestAnimationFrame(frame);
19708      })();
19709      (function mouseEffects() {
19710        var heroTitle = document.getElementById('hero-title');
19711        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
19712        function tick() {
19713          raf = null;
19714          if (heroTitle) {
19715            var r = heroTitle.getBoundingClientRect();
19716            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
19717            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
19718            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
19719          }
19720        }
19721        document.addEventListener('mousemove', function(e) {
19722          mx = e.clientX; my = e.clientY;
19723          if (!raf) raf = requestAnimationFrame(tick);
19724        });
19725        document.addEventListener('mouseleave', function() {
19726          if (heroTitle) {
19727            heroTitle.style.transition = 'transform 0.5s ease';
19728            heroTitle.style.transform = '';
19729            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
19730          }
19731        });
19732        document.querySelectorAll('.action-card').forEach(function(card) {
19733          card.addEventListener('mousemove', function(e) {
19734            var rect = card.getBoundingClientRect();
19735            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
19736            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
19737            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
19738            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
19739          });
19740          card.addEventListener('mouseleave', function() {
19741            card.style.transition = '';
19742            card.style.transform = '';
19743          });
19744        });
19745      })();
19746      (function chipSlideshow() {
19747        var slides = [
19748          [{v:'60',l:'Languages'},{v:'Rust · Go · Python',l:'and 57 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
19749          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
19750          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
19751          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
19752          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
19753        ];
19754        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
19755        var indices = [0,0,0,0,0];
19756        var paused = [false,false,false,false,false];
19757        chips.forEach(function(chip, i) {
19758          chip.addEventListener('mouseenter', function() { paused[i] = true; });
19759          chip.addEventListener('mouseleave', function() { paused[i] = false; });
19760        });
19761        function advance(i) {
19762          if (paused[i]) return;
19763          var chip = chips[i];
19764          var inner = chip.querySelector('.chip-slide');
19765          if (!inner) return;
19766          inner.classList.add('fading');
19767          setTimeout(function() {
19768            indices[i] = (indices[i] + 1) % slides[i].length;
19769            var s = slides[i][indices[i]];
19770            chip.querySelector('.info-chip-val').textContent = s.v;
19771            chip.querySelector('.info-chip-label').textContent = s.l;
19772            inner.classList.remove('fading');
19773          }, 720);
19774        }
19775        setInterval(function() {
19776          chips.forEach(function(chip, i) { advance(i); });
19777        }, 6000);
19778      })();
19779      (function cardLiveData() {
19780        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
19781          var el = document.getElementById('acp-scan-stat');
19782          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
19783        }).catch(function(){});
19784        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
19785          var el = document.getElementById('acp-test-stat');
19786          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
19787        }).catch(function(){});
19788        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
19789          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
19790          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
19791          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
19792          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
19793          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
19794          var stat = document.getElementById('acp-int-stat');
19795          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
19796        }).catch(function(){});
19797        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
19798          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
19799        }).catch(function(){});
19800      })();
19801    })();
19802  </script>
19803  <script nonce="{{ csp_nonce }}">
19804  (function(){
19805    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'}];
19806    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);});}
19807    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19808    function init(){
19809      var btn=document.getElementById('settings-btn');if(!btn)return;
19810      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19811      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>';
19812      document.body.appendChild(m);
19813      var g=document.getElementById('scheme-grid');
19814      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);});
19815      var cl=document.getElementById('settings-close');
19816      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);
19817      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');});
19818      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19819      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19820    }
19821    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19822  }());
19823  </script>
19824  <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 }} — 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>
19825</body>
19826</html>
19827"##,
19828    ext = "html"
19829)]
19830struct SplashTemplate {
19831    csp_nonce: String,
19832    server_mode: bool,
19833    lan_ip: Option<String>,
19834    port: u16,
19835    version: &'static str,
19836    has_api_key: bool,
19837}
19838
19839// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
19840
19841#[derive(Template)]
19842#[template(
19843    source = r##"
19844<!doctype html>
19845<html lang="en">
19846<head>
19847  <meta charset="utf-8">
19848  <meta name="viewport" content="width=device-width, initial-scale=1">
19849  <title>OxideSLOC — Start a Scan</title>
19850  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19851  <style nonce="{{ csp_nonce }}">
19852    :root {
19853      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
19854      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19855      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19856      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19857      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19858    }
19859    body.dark-theme {
19860      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19861      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19862    }
19863    *{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;}
19864    .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);}
19865    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19866    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
19867    .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));}
19868    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
19869    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19870    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19871    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19872    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19873    @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; } }
19874    .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;}
19875    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19876    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19877    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19878    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19879    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19880    .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;}
19881    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19882    .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);}
19883    .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;}
19884    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19885    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19886    .settings-modal-body{padding:14px 16px 16px;}
19887    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19888    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19889    .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;}
19890    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19891    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19892    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19893    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19894    .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;}
19895    .tz-select:focus{border-color:var(--oxide);}
19896    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
19897    .page-header{text-align:center;margin-bottom:16px;}
19898    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
19899    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
19900    /* Cards */
19901    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
19902    .option-card-wrap{position:relative;}
19903    .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;}
19904    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
19905    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19906    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
19907    .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;}
19908    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
19909    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
19910    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
19911    .card-top-row{display:flex;align-items:center;gap:20px;}
19912    /* Two-column layout inside each card */
19913    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
19914    .card-left{display:flex;align-items:flex-start;min-width:0;}
19915    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
19916    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
19917    .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);}
19918    .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);}
19919    .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);}
19920    .card-text{min-width:0;}
19921    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
19922    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
19923    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
19924    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
19925    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
19926    /* Right CTA column */
19927    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
19928    .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;}
19929    /* Re-scan count badge */
19930    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
19931    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
19932    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
19933    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
19934    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
19935    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
19936    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
19937    body.dark-theme .btn-secondary{color:var(--oxide);}
19938    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
19939    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
19940    /* File input overlay — must be full-width so it aligns with other card-right buttons */
19941    .file-input-wrap{position:relative;width:100%;}
19942    .file-input-wrap .btn{width:100%;}
19943    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
19944    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19945    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19946    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19947    .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;}
19948    @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));}}
19949    /* Recent list (card 3 — full-width section below header) */
19950    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
19951    .recent-list{display:flex;flex-direction:column;gap:8px;}
19952    .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;}
19953    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
19954    .recent-item-info{flex:1;min-width:0;}
19955    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
19956    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
19957    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
19958    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
19959    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19960    .site-footer a{color:var(--muted);}
19961    @media(max-width:680px){
19962      .card-body{grid-template-columns:1fr;}
19963      .card-right{flex-direction:row;flex-wrap:wrap;}
19964      .btn{flex:1;}
19965    }
19966    .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;}
19967    .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;}
19968    .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;}
19969  </style>
19970</head>
19971<body>
19972  <div class="background-watermarks" aria-hidden="true">
19973    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19974    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19975    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19976    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19977    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19978    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19979    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19980  </div>
19981  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19982  <div class="top-nav">
19983    <div class="top-nav-inner">
19984      <a class="brand" href="/">
19985        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19986        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19987      </a>
19988      <div class="nav-right">
19989        <a class="nav-pill" href="/">Home</a>
19990        <div class="nav-dropdown">
19991          <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>
19992          <div class="nav-dropdown-menu">
19993            <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>
19994          </div>
19995        </div>
19996        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19997        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19998        <div class="nav-dropdown">
19999          <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>
20000          <div class="nav-dropdown-menu">
20001            <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>
20002          </div>
20003        </div>
20004        <div class="server-status-wrap" id="server-status-wrap">
20005          <div class="nav-pill server-online-pill" id="server-status-pill">
20006            <span class="status-dot" id="status-dot"></span>
20007            <span id="server-status-label">Server</span>
20008            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20009          </div>
20010          <div class="server-status-tip">
20011            OxideSLOC is running — accessible on your network.
20012            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20013          </div>
20014        </div>
20015        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20016          <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>
20017        </button>
20018        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20019          <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>
20020          <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>
20021        </button>
20022      </div>
20023    </div>
20024  </div>
20025
20026  <div class="page">
20027    <div class="page-header">
20028      <h1>How would you like to scan?</h1>
20029      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
20030    </div>
20031
20032    <div class="option-grid">
20033
20034      <!-- Option 1: New scan -->
20035      <div class="option-card-wrap">
20036        <div class="option-card">
20037        <div class="option-icon new-scan">
20038          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
20039        </div>
20040        <div class="card-body">
20041          <div class="card-left">
20042            <div class="card-text">
20043              <div class="option-title">Start a new scan</div>
20044              <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>
20045              <ul class="feature-list">
20046                <li>Live project scope preview before you run</li>
20047                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
20048                <li>HTML, PDF, and JSON output — your choice</li>
20049              </ul>
20050            </div>
20051          </div>
20052          <div class="card-right">
20053            <a class="btn btn-primary" href="/scan">
20054              Configure &amp; scan
20055              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
20056            </a>
20057            <p class="card-tip">Full 4-step setup · all options</p>
20058          </div>
20059        </div>
20060        </div>
20061      </div>
20062
20063      <!-- Option 2: Load from config file -->
20064      <div class="option-card-wrap">
20065        <div class="option-card">
20066        <div class="option-icon load-config">
20067          <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>
20068        </div>
20069        <div class="card-body">
20070          <div class="card-left">
20071            <div class="card-text">
20072              <div class="option-title">Load a saved config</div>
20073              <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>
20074              <ul class="feature-list">
20075                <li>All 15 settings restored from the file</li>
20076                <li>Fully editable — change path or output dir</li>
20077                <li>Works with any scan-config.json</li>
20078              </ul>
20079            </div>
20080          </div>
20081          <div class="card-right">
20082            <div class="file-input-wrap">
20083              <button class="btn btn-secondary" id="load-config-btn" type="button">
20084                <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>
20085                Choose config file
20086              </button>
20087              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
20088            </div>
20089            <p class="card-tip" id="config-file-name">Exported after every scan</p>
20090          </div>
20091        </div>
20092        </div>
20093      </div>
20094
20095      <!-- Option 3: Re-scan recent project -->
20096      <div class="option-card-wrap">
20097        <div class="option-card" id="recent-card">
20098        <div class="card-top-row">
20099          <div class="option-icon rescan">
20100            <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>
20101          </div>
20102          <div class="card-body">
20103            <div class="card-left">
20104              <div class="card-text">
20105                <div class="option-title">Re-scan a recent project</div>
20106                <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>
20107                <ul class="feature-list">
20108                  <li>All 15+ settings restored from the saved config</li>
20109                  <li>Path and output dir are editable before running</li>
20110                  <li>Only scans with a saved config appear here</li>
20111                </ul>
20112              </div>
20113            </div>
20114            <div class="card-right">
20115              <div class="rescan-count-box">
20116                <div class="rescan-count-num" id="rescan-count-num">—</div>
20117                <div class="rescan-count-label">saved configs</div>
20118              </div>
20119              <a class="btn btn-secondary" href="/view-reports">
20120                <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>
20121                View all runs
20122              </a>
20123              <p class="card-tip">Opens run history</p>
20124            </div>
20125          </div>
20126        </div>
20127        <div class="section-divider"></div>
20128        <div class="recent-list" id="recent-list">
20129          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
20130        </div>
20131        </div>
20132      </div>
20133
20134    </div>
20135  </div>
20136
20137  <footer class="site-footer">
20138    local code analysis - metrics, history and reports
20139    &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>
20140    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20141    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20142    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20143    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20144  </footer>
20145
20146  <script nonce="{{ csp_nonce }}">
20147    (function () {
20148      var storageKey = 'oxide-sloc-theme';
20149      var body = document.body;
20150      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20151      var toggle = document.getElementById('theme-toggle');
20152      if (toggle) toggle.addEventListener('click', function () {
20153        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20154        body.classList.toggle('dark-theme', next === 'dark');
20155        try { localStorage.setItem(storageKey, next); } catch(e) {}
20156      });
20157
20158      (function randomizeWatermarks() {
20159        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20160        if (!wms.length) return;
20161        var placed = [];
20162        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; }
20163        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]; }
20164        var half = Math.floor(wms.length / 2);
20165        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; });
20166      })();
20167      (function spawnCodeParticles() {
20168        var container = document.getElementById('code-particles');
20169        if (!container) return;
20170        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'];
20171        var count = 38;
20172        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); }
20173      })();
20174      // Recent scans data injected from server
20175      var recentScans = {{ recent_scans_json|safe }};
20176
20177      function configToParams(cfg) {
20178        var p = new URLSearchParams();
20179        p.set('prefilled', '1');
20180        if (cfg.path) p.set('path', cfg.path);
20181        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
20182        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
20183        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
20184        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
20185        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
20186        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
20187        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
20188        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
20189        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
20190        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
20191        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
20192        if (cfg.report_title) p.set('report_title', cfg.report_title);
20193        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
20194        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
20195        return p;
20196      }
20197
20198      // Build recent scan list (capped at 3 visible entries)
20199      var list = document.getElementById('recent-list');
20200      var noNote = document.getElementById('no-recent-note');
20201      var hasAny = false;
20202      var MAX_RECENT = 3;
20203      if (Array.isArray(recentScans)) {
20204        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
20205        var shown = 0;
20206        validEntries.forEach(function (entry) {
20207          if (shown >= MAX_RECENT) return;
20208          shown++;
20209          hasAny = true;
20210          var item = document.createElement('div');
20211          item.className = 'recent-item';
20212          item.title = 'Restore all settings and open wizard';
20213          item.innerHTML =
20214            '<div class="recent-item-info">' +
20215              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
20216              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
20217            '</div>' +
20218            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
20219          item.addEventListener('click', function () {
20220            var params = configToParams(entry.config);
20221            window.location.href = '/scan?' + params.toString();
20222          });
20223          list.appendChild(item);
20224        });
20225        if (validEntries.length > MAX_RECENT) {
20226          var moreEl = document.createElement('div');
20227          moreEl.className = 'recent-more-link';
20228          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
20229          list.appendChild(moreEl);
20230        }
20231      }
20232      if (hasAny && noNote) noNote.style.display = 'none';
20233      // Update count badge
20234      var countEl = document.getElementById('rescan-count-num');
20235      if (countEl) {
20236        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
20237        countEl.textContent = total > 0 ? total : '0';
20238      }
20239
20240      // Config file loader
20241      var fileInput = document.getElementById('config-file-input');
20242      var fileName = document.getElementById('config-file-name');
20243      var loadBtn = document.getElementById('load-config-btn');
20244      // Wire the visible button to open the hidden file picker.
20245      if (loadBtn && fileInput) {
20246        loadBtn.addEventListener('click', function () { fileInput.click(); });
20247      }
20248      if (fileInput) {
20249        fileInput.addEventListener('change', function () {
20250          var file = fileInput.files && fileInput.files[0];
20251          if (!file) return;
20252          if (fileName) fileName.textContent = '✓ ' + file.name;
20253          var reader = new FileReader();
20254          reader.onload = function (e) {
20255            try {
20256              var cfg = JSON.parse(e.target.result);
20257              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
20258              var params = configToParams(cfg);
20259              window.location.href = '/scan?' + params.toString();
20260            } catch (err) {
20261              alert('Could not parse config file: ' + err.message);
20262            }
20263          };
20264          reader.readAsText(file);
20265        });
20266      }
20267
20268      function escHtml(s) {
20269        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
20270      }
20271    })();
20272  </script>
20273  <script nonce="{{ csp_nonce }}">
20274  (function(){
20275    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'}];
20276    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);});}
20277    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20278    function init(){
20279      var btn=document.getElementById('settings-btn');if(!btn)return;
20280      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20281      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>';
20282      document.body.appendChild(m);
20283      var g=document.getElementById('scheme-grid');
20284      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);});
20285      var cl=document.getElementById('settings-close');
20286      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);
20287      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');});
20288      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20289      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20290    }
20291    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20292  }());
20293  </script>
20294  <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]';
20295  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;}
20296  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>
20297</body>
20298</html>
20299"##,
20300    ext = "html"
20301)]
20302struct ScanSetupTemplate {
20303    version: &'static str,
20304    recent_scans_json: String,
20305    csp_nonce: String,
20306}
20307
20308#[derive(Template)]
20309#[template(
20310    source = r##"
20311<!doctype html>
20312<html lang="en">
20313<head>
20314  <meta charset="utf-8">
20315  <meta name="viewport" content="width=device-width, initial-scale=1">
20316  <title>OxideSLOC | {{ report_title }} | Report</title>
20317  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20318  <style nonce="{{ csp_nonce }}">
20319    :root {
20320      --radius: 18px;
20321      --bg: #f5efe8;
20322      --surface: rgba(255,255,255,0.82);
20323      --surface-2: #fbf7f2;
20324      --surface-3: #efe6dc;
20325      --line: #e6d0bf;
20326      --line-strong: #dcb89f;
20327      --text: #43342d;
20328      --muted: #7b675b;
20329      --muted-2: #a08777;
20330      --nav: #b85d33;
20331      --nav-2: #7a371b;
20332      --accent: #6f9bff;
20333      --accent-2: #4a78ee;
20334      --oxide: #d37a4c;
20335      --oxide-2: #b35428;
20336      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
20337      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
20338      --success-bg: #e8f5ed;
20339      --success-text: #1a8f47;
20340      --info-bg: #eef3ff;
20341      --info-text: #4467d8;
20342    }
20343
20344    body.dark-theme {
20345      --bg: #1b1511;
20346      --surface: #261c17;
20347      --surface-2: #2d221d;
20348      --surface-3: #372922;
20349      --line: #524238;
20350      --line-strong: #6c5649;
20351      --text: #f5ece6;
20352      --muted: #c7b7aa;
20353      --muted-2: #aa9485;
20354      --nav: #b85d33;
20355      --nav-2: #7a371b;
20356      --accent: #6f9bff;
20357      --accent-2: #4a78ee;
20358      --oxide: #d37a4c;
20359      --oxide-2: #b35428;
20360      --shadow: 0 18px 42px rgba(0,0,0,0.28);
20361      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
20362      --success-bg: #163927;
20363      --success-text: #8fe2a8;
20364      --info-bg: #1c2847;
20365      --info-text: #a9c1ff;
20366    }
20367
20368    * { box-sizing: border-box; }
20369    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); }
20370    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
20371    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
20372    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
20373    .top-nav, .page { position: relative; z-index: 2; }
20374    .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); }
20375    .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; }
20376    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
20377    .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)); }
20378    .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; }
20379    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
20380    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
20381    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
20382    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
20383    .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; }
20384    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
20385    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20386    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
20387    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20388    @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; } }
20389    .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; }
20390    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
20391    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
20392    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
20393    .theme-toggle .icon-sun { display:none; }
20394    body.dark-theme .theme-toggle .icon-sun { display:block; }
20395    body.dark-theme .theme-toggle .icon-moon { display:none; }
20396    .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;}
20397    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20398    .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);}
20399    .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;}
20400    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20401    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20402    .settings-modal-body{padding:14px 16px 16px;}
20403    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20404    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20405    .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;}
20406    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20407    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20408    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20409    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20410    .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;}
20411    .tz-select:focus{border-color:var(--oxide);}
20412    .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; }
20413    .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;}
20414    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
20415    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
20416    .hero, .panel { padding: 22px; }
20417    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
20418    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
20419    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
20420    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
20421    .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; }
20422    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
20423    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
20424    .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; }
20425    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
20426    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
20427    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
20428    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
20429    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
20430    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
20431    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; min-width:92px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; }
20432    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
20433    .delta-card-val { font-size:16px; font-weight:800; }
20434    .delta-card-val.pos { color:#1e7e34; }
20435    .delta-card-val.neg { color:var(--neg); }
20436    .delta-card-val.mod { color:#b35428; }
20437    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
20438    .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .2s ease; z-index:200; }
20439    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20440    .delta-card-inline:hover .delta-card-tip { opacity:1; }
20441    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
20442    .compare-ts { font-size:13px; color:var(--muted); }
20443    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
20444    .compare-arrow { color: var(--muted); }
20445    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
20446    .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; }
20447    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
20448    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
20449    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
20450    .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; }
20451    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
20452    .run-mgmt-card .action-buttons { justify-content:center; }
20453    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
20454    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
20455    .button, .copy-button {
20456      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;
20457    }
20458    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
20459    @keyframes spin { to { transform: rotate(360deg); } }
20460    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
20461    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
20462    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
20463    .path-item strong { display: block; margin-bottom: 6px; }
20464    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
20465    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
20466    .path-subitem { flex: 1; }
20467    .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); }
20468    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); }
20469    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
20470    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
20471    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
20472    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
20473    th { color: var(--muted); font-weight: 700; }
20474    tr:last-child td { border-bottom: none; }
20475    #subm-tbl col:nth-child(1){width:15%;}
20476    #subm-tbl col:nth-child(2){width:31%;}
20477    #subm-tbl col:nth-child(3){width:9%;}
20478    #subm-tbl col:nth-child(4){width:9%;}
20479    #subm-tbl col:nth-child(5){width:9%;}
20480    #subm-tbl col:nth-child(6){width:9%;}
20481    #subm-tbl col:nth-child(7){width:9%;}
20482    #subm-tbl col:nth-child(8){width:9%;}
20483    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
20484    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
20485    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
20486    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
20487    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
20488    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
20489    .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; }
20490    .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; }
20491    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
20492    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
20493    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
20494    .muted { color: var(--muted); }
20495    /* Run-ID chip row (mirrors HTML report) */
20496    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
20497    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
20498    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
20499    .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; }
20500    .run-id-chip[data-copy] { cursor:pointer; }
20501    a.run-id-chip { text-decoration:none; cursor:pointer; }
20502    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
20503    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
20504    .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; }
20505    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
20506    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20507    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
20508    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
20509    a.commit-link-value { color:inherit; text-decoration:none; }
20510    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
20511    .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 0.18s ease; z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
20512    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20513    .run-id-chip:hover .chip-tooltip { opacity:1; }
20514    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
20515    .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; }
20516    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
20517    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
20518    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
20519    /* Meta chips row */
20520    .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%; }
20521    .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; }
20522    .meta-chip:last-child { border-right:none; }
20523    .meta-chip b { color:var(--text); font-weight:700; }
20524    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20525    .site-footer a{color:var(--muted);}
20526    .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; }
20527    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
20528    .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; }
20529    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
20530    /* Stat chips (matches HTML report) */
20531    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
20532    @media(max-width:1200px){.summary-strip{grid-template-columns:repeat(4,1fr);}}
20533    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20534    .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; overflow:visible; }
20535    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
20536    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
20537    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
20538    .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; }
20539    .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%); 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 .2s ease; z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
20540    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20541    .stat-chip:hover .stat-chip-tip { opacity:1; }
20542    .cocomo-box { background:var(--surface-2); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
20543    .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; }
20544    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
20545    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
20546    .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); }
20547    .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; 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 .2s ease; z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
20548    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
20549    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; }
20550    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
20551    /* Submodule panel */
20552    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
20553    /* Metrics tables stack */
20554    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
20555    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
20556    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
20557    .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)); }
20558    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
20559    /* Metrics table */
20560    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
20561    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
20562    .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; }
20563    .metrics-table thead th:not(:first-child) { text-align: right; }
20564    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
20565    .metrics-table tbody tr:last-child td { border-bottom: none; }
20566    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
20567    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
20568    .metrics-table tbody tr:hover td { background: var(--surface-2); }
20569    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
20570    .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; }
20571    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
20572    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
20573    .mt-val-pos { color: var(--pos); font-weight: 700; }
20574    .mt-val-neg { color: var(--neg); font-weight: 700; }
20575    .mt-val-zero { color: var(--muted); }
20576    .mt-val-mod { color: var(--oxide-2); }
20577    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
20578    @media (max-width: 1180px) {
20579      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
20580      .nav-project-slot, .nav-status { justify-content:flex-start; }
20581      .hero-top { flex-direction: column; }
20582      .run-mgmt-strip { flex-direction: column; }
20583    }
20584    .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;}
20585    @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));}}
20586    .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;}
20587    /* ── Result-page chart controls ─────────────────────────────────────────── */
20588    .r-chart-section{margin-bottom:24px;}
20589    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
20590    .section-pair > .panel{flex-shrink:0;}
20591    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
20592    .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;}
20593    .r-chart-select:focus{border-color:var(--accent);}
20594    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
20595    .r-chart-container svg{display:block;width:100%;height:auto;}
20596    .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;}
20597    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
20598    .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;}
20599    .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);}
20600    .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;}
20601    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
20602    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
20603    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
20604    .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;}
20605    .r-chart-modal-close:hover{opacity:.7;}
20606    body.dark-theme .r-chart-modal{background:var(--surface);}
20607    .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;}
20608    .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);}
20609    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
20610    .lang-bar-row:hover{transform:translateY(-2px);}
20611    .lang-bar-row .rchit:hover{filter:none;transform:none;}
20612    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
20613    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
20614    .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;}
20615    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
20616    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
20617    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
20618    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
20619    #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;}
20620    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
20621    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
20622    .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;}
20623    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
20624    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
20625    .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;}
20626    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
20627    .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;}
20628    .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;}
20629    body.has-report-banner .top-nav{top:27px;}
20630    body.has-report-banner{padding-bottom:27px;}
20631  </style>
20632</head>
20633<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
20634  <div class="background-watermarks" aria-hidden="true">
20635    <img src="/images/logo/logo-text.png" alt="" />
20636    <img src="/images/logo/logo-text.png" alt="" />
20637    <img src="/images/logo/logo-text.png" alt="" />
20638    <img src="/images/logo/logo-text.png" alt="" />
20639    <img src="/images/logo/logo-text.png" alt="" />
20640    <img src="/images/logo/logo-text.png" alt="" />
20641    <img src="/images/logo/logo-text.png" alt="" />
20642    <img src="/images/logo/logo-text.png" alt="" />
20643    <img src="/images/logo/logo-text.png" alt="" />
20644    <img src="/images/logo/logo-text.png" alt="" />
20645    <img src="/images/logo/logo-text.png" alt="" />
20646    <img src="/images/logo/logo-text.png" alt="" />
20647    <img src="/images/logo/logo-text.png" alt="" />
20648    <img src="/images/logo/logo-text.png" alt="" />
20649  </div>
20650  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20651  {% if let Some(banner) = report_header_footer %}
20652  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
20653  {% endif %}
20654  <div class="top-nav">
20655    <div class="top-nav-inner">
20656      <a class="brand" href="/">
20657        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20658        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
20659      </a>
20660      <div class="nav-project-slot">
20661        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
20662      </div>
20663      <div class="nav-status">
20664        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
20665        <div class="nav-dropdown">
20666          <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>
20667          <div class="nav-dropdown-menu">
20668            <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>
20669          </div>
20670        </div>
20671        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
20672        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20673        <div class="nav-dropdown">
20674          <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>
20675          <div class="nav-dropdown-menu">
20676            <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>
20677          </div>
20678        </div>
20679        <div class="server-status-wrap" id="server-status-wrap">
20680          <div class="nav-pill server-online-pill" id="server-status-pill">
20681            <span class="status-dot" id="status-dot"></span>
20682            <span id="server-status-label">Server</span>
20683            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20684          </div>
20685          <div class="server-status-tip">
20686            OxideSLOC is running — accessible on your network.
20687            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20688          </div>
20689        </div>
20690        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20691          <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>
20692        </button>
20693        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
20694          <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>
20695          <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>
20696        </button>
20697      </div>
20698    </div>
20699  </div>
20700
20701  <div class="page">
20702    <section class="hero">
20703      <div class="hero-top">
20704        <div>
20705          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
20706            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
20707            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
20708            <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>
20709          </div>
20710        </div>
20711        <div class="hero-quick-actions">
20712          {% if server_mode %}
20713          <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>
20714          {% else %}
20715          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
20716          {% endif %}
20717          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
20718          {% if !server_mode %}
20719          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
20720          {% endif %}
20721          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
20722          <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>
20723        </div>
20724      </div>
20725
20726      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
20727      <div class="run-id-row">
20728        <span class="run-id-chip" data-copy="{{ run_id }}">
20729          <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>
20730          <span class="run-id-chip-value">{{ run_id }}</span>
20731          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
20732        </span>
20733        {% match git_commit_long %}
20734          {% when Some with (long_sha) %}
20735          {% match git_commit_url %}
20736            {% when Some with (commit_url) %}
20737            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
20738              <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>
20739              <span class="run-id-chip-value">{{ long_sha }}</span>
20740              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
20741            </a>
20742            {% when None %}
20743            <span class="run-id-chip" data-copy="{{ long_sha }}">
20744              <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>
20745              <span class="run-id-chip-value">{{ long_sha }}</span>
20746              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
20747            </span>
20748          {% endmatch %}
20749          {% when None %}
20750          <span class="run-id-chip muted-chip">
20751            <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>
20752            <span class="run-id-chip-value">Not detected</span>
20753            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
20754          </span>
20755        {% endmatch %}
20756        {% match git_branch %}
20757          {% when Some with (branch) %}
20758          {% match git_branch_url %}
20759            {% when Some with (branch_url) %}
20760            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
20761              <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>
20762              <span class="run-id-chip-value">{{ branch }}</span>
20763              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
20764            </a>
20765            {% when None %}
20766            <span class="run-id-chip">
20767              <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>
20768              <span class="run-id-chip-value">{{ branch }}</span>
20769              <span class="chip-tooltip">Git branch active at scan time</span>
20770            </span>
20771          {% endmatch %}
20772          {% when None %}
20773          <span class="run-id-chip muted-chip">
20774            <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>
20775            <span class="run-id-chip-value">Not detected</span>
20776            <span class="chip-tooltip">No Git branch was found for this scan</span>
20777          </span>
20778        {% endmatch %}
20779        {% match git_author %}
20780          {% when Some with (author) %}
20781          <span class="run-id-chip" data-author="{{ author }}">
20782            <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>
20783            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
20784            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
20785          </span>
20786          {% when None %}
20787          <span class="run-id-chip muted-chip">
20788            <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>
20789            <span class="run-id-chip-value">Not detected</span>
20790            <span class="chip-tooltip">No commit author was found for this scan</span>
20791          </span>
20792        {% endmatch %}
20793      </div>
20794
20795      <!-- Scan metadata row -->
20796      <div class="meta">
20797        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
20798        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
20799        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
20800        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
20801        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
20802      </div>
20803
20804      <!-- All summary stat chips in one unified strip (8 columns) -->
20805      <div class="summary-strip">
20806        <div class="stat-chip" data-raw="{{ physical_lines }}">
20807          <div class="stat-chip-label">Physical lines</div>
20808          <div class="stat-chip-val">{{ physical_lines }}</div>
20809          <div class="stat-chip-exact"></div>
20810          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
20811        </div>
20812        <div class="stat-chip" data-raw="{{ code_lines }}">
20813          <div class="stat-chip-label">Code</div>
20814          <div class="stat-chip-val">{{ code_lines }}</div>
20815          <div class="stat-chip-exact"></div>
20816          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
20817        </div>
20818        <div class="stat-chip" data-raw="{{ comment_lines }}">
20819          <div class="stat-chip-label">Comments</div>
20820          <div class="stat-chip-val">{{ comment_lines }}</div>
20821          <div class="stat-chip-exact"></div>
20822          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
20823        </div>
20824        <div class="stat-chip" data-raw="{{ blank_lines }}">
20825          <div class="stat-chip-label">Blank</div>
20826          <div class="stat-chip-val">{{ blank_lines }}</div>
20827          <div class="stat-chip-exact"></div>
20828          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
20829        </div>
20830        <div class="stat-chip" data-raw="{{ mixed_lines }}">
20831          <div class="stat-chip-label">Mixed separate</div>
20832          <div class="stat-chip-val">{{ mixed_lines }}</div>
20833          <div class="stat-chip-exact"></div>
20834          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
20835        </div>
20836        <div class="stat-chip" data-raw="{{ functions }}">
20837          <div class="stat-chip-label">Functions</div>
20838          <div class="stat-chip-val">{{ functions }}</div>
20839          <div class="stat-chip-exact"></div>
20840          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
20841        </div>
20842        <div class="stat-chip" data-raw="{{ classes }}">
20843          <div class="stat-chip-label">Classes / Types</div>
20844          <div class="stat-chip-val">{{ classes }}</div>
20845          <div class="stat-chip-exact"></div>
20846          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
20847        </div>
20848        <div class="stat-chip" data-raw="{{ variables }}">
20849          <div class="stat-chip-label">Variables</div>
20850          <div class="stat-chip-val">{{ variables }}</div>
20851          <div class="stat-chip-exact"></div>
20852          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
20853        </div>
20854        <div class="stat-chip" data-raw="{{ imports }}">
20855          <div class="stat-chip-label">Imports</div>
20856          <div class="stat-chip-val">{{ imports }}</div>
20857          <div class="stat-chip-exact"></div>
20858          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
20859        </div>
20860        <div class="stat-chip" data-raw="{{ test_count }}">
20861          <div class="stat-chip-label">Tests</div>
20862          <div class="stat-chip-val">{{ test_count }}</div>
20863          <div class="stat-chip-exact"></div>
20864          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
20865        </div>
20866        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
20867          <div class="stat-chip-label">Code density</div>
20868          <div class="stat-chip-val stat-chip-density-val">—</div>
20869          <div class="stat-chip-exact"></div>
20870          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
20871        </div>
20872        <div class="stat-chip" data-raw="{{ files_analyzed }}">
20873          <div class="stat-chip-label">Files analyzed</div>
20874          <div class="stat-chip-val">{{ files_analyzed }}</div>
20875          <div class="stat-chip-exact"></div>
20876          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
20877        </div>
20878        {% if cyclomatic_complexity > 0 %}
20879        <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 %}>
20880          <div class="stat-chip-label">Complexity score</div>
20881          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
20882          <div class="stat-chip-exact"></div>
20883          <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>
20884        </div>
20885        {% endif %}
20886        {% if let Some(ls) = lsloc %}
20887        <div class="stat-chip" data-raw="{{ ls }}">
20888          <div class="stat-chip-label">Logical SLOC</div>
20889          <div class="stat-chip-val">{{ ls }}</div>
20890          <div class="stat-chip-exact"></div>
20891          <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>
20892        </div>
20893        {% endif %}
20894        {% if uloc > 0 %}
20895        <div class="stat-chip" data-raw="{{ uloc }}">
20896          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
20897          <div class="stat-chip-val">{{ uloc }}</div>
20898          <div class="stat-chip-exact"></div>
20899          <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>
20900        </div>
20901        {% endif %}
20902        {% if uloc > 0 && dryness_pct_str != "" %}
20903        <div class="stat-chip">
20904          <div class="stat-chip-label">DRYness</div>
20905          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
20906          <div class="stat-chip-exact"></div>
20907          <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>
20908        </div>
20909        {% endif %}
20910        {% if duplicate_group_count > 0 %}
20911        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
20912          <div class="stat-chip-label">Duplicate groups</div>
20913          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
20914          <div class="stat-chip-exact"></div>
20915          <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>
20916        </div>
20917        {% endif %}
20918      </div>
20919
20920      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
20921      <div class="compare-banner">
20922        <div class="compare-banner-body">
20923          <div class="compare-banner-top">
20924          <div class="compare-banner-meta">
20925            <span class="compare-label">Previous scan</span>
20926            <span class="compare-ts">{{ prev_ts }}</span>
20927            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
20928            {% if let Some(prev_code) = prev_run_code_lines %}
20929            <div class="compare-banner-stats" style="margin-top:4px;">
20930              <span>Code before: <strong>{{ prev_code }}</strong></span>
20931              <span class="compare-arrow">→</span>
20932              <span>Code now: <strong>{{ code_lines }}</strong></span>
20933              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
20934              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
20935            </div>
20936            {% endif %}
20937          </div>
20938          {% if delta_lines_added.is_some() %}
20939          <div class="delta-cards-inline">
20940            <div class="delta-card-inline">
20941              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
20942              <div class="delta-card-lbl">lines added</div>
20943              <div class="delta-card-tip">Code lines added since the previous scan</div>
20944            </div>
20945            <div class="delta-card-inline">
20946              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
20947              <div class="delta-card-lbl">lines removed</div>
20948              <div class="delta-card-tip">Code lines removed since the previous scan</div>
20949            </div>
20950            <div class="delta-card-inline">
20951              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
20952              <div class="delta-card-lbl">unmodified lines</div>
20953              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
20954            </div>
20955            <div class="delta-card-inline">
20956              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
20957              <div class="delta-card-lbl">files modified</div>
20958              <div class="delta-card-tip">Files with at least one line changed</div>
20959            </div>
20960            <div class="delta-card-inline">
20961              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
20962              <div class="delta-card-lbl">files added</div>
20963              <div class="delta-card-tip">New files added since the previous scan</div>
20964            </div>
20965            <div class="delta-card-inline">
20966              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
20967              <div class="delta-card-lbl">files removed</div>
20968              <div class="delta-card-tip">Files deleted since the previous scan</div>
20969            </div>
20970            <div class="delta-card-inline">
20971              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
20972              <div class="delta-card-lbl">files unchanged</div>
20973              <div class="delta-card-tip">Files with no changes since the previous scan</div>
20974            </div>
20975          </div>
20976          {% else %}
20977          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
20978            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
20979          </p>
20980          {% endif %}
20981          </div>
20982          <div class="compare-banner-actions">
20983            <div class="compare-banner-actions-left">
20984              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
20985              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
20986            </div>
20987            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
20988          </div>
20989        </div>
20990      </div>
20991      {% endif %}{% endif %}
20992
20993      <div class="action-grid">
20994        <div class="action-card">
20995          <h3>HTML report</h3>
20996          <div class="action-buttons">
20997            {% match html_url %}
20998              {% when Some with (url) %}
20999                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
21000              {% when None %}{% endmatch %}
21001            {% match html_download_url %}
21002              {% when Some with (url) %}
21003                <a class="button secondary" href="{{ url }}">Download HTML</a>
21004              {% when None %}{% endmatch %}
21005            {% match html_path %}
21006              {% when Some with (_path) %}{% when None %}{% endmatch %}
21007            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
21008          </div>
21009        </div>
21010        <div class="action-card">
21011          <h3>PDF report</h3>
21012          <div class="action-buttons">
21013            {% match pdf_url %}
21014              {% when Some with (url) %}
21015                {% if pdf_generating %}
21016                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
21017                    <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>
21018                    Generating PDF…
21019                  </button>
21020                {% else %}
21021                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
21022                {% endif %}
21023              {% when None %}
21024                {% match html_url %}
21025                  {% when Some with (_hurl) %}
21026                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
21027                    <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>
21028                  {% when None %}
21029                    <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;">
21030                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
21031                    </p>
21032                {% endmatch %}
21033            {% endmatch %}
21034            {% match pdf_download_url %}
21035              {% when Some with (url) %}
21036                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
21037              {% when None %}{% endmatch %}
21038            {% match pdf_url %}
21039              {% when Some with (_) %}
21040                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
21041              {% when None %}{% endmatch %}
21042          </div>
21043        </div>
21044        <div class="action-card">
21045          <h3>JSON result</h3>
21046          <div class="action-buttons">
21047            {% match json_url %}
21048              {% when Some with (url) %}
21049                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
21050              {% when None %}{% endmatch %}
21051            {% match json_download_url %}
21052              {% when Some with (url) %}
21053                <a class="button secondary" href="{{ url }}">Download JSON</a>
21054              {% when None %}{% endmatch %}
21055            {% match json_path %}
21056              {% when Some with (_path) %}
21057                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
21058              {% when None %}
21059                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
21060              {% endmatch %}
21061          </div>
21062        </div>
21063        <div class="action-card">
21064          <h3>Scan config</h3>
21065          <div class="action-buttons">
21066            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
21067            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
21068            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
21069          </div>
21070        </div>
21071        {% if confluence_configured %}
21072        <div class="action-card" id="confluenceCard">
21073          <h3>Confluence</h3>
21074          <div class="action-buttons">
21075            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
21076            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
21077          </div>
21078          <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>
21079        </div>
21080        {% endif %}
21081      </div>
21082      {% if confluence_configured %}
21083      <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;">
21084        <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);">
21085          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
21086          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
21087          <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;">
21088          <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>
21089          <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;">
21090          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
21091          <div style="display:flex;gap:10px;justify-content:flex-end;">
21092            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
21093            <button class="button" id="confSubmitBtn" type="button">Post</button>
21094          </div>
21095        </div>
21096      </div>
21097      {% endif %}
21098      <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;">
21099        <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);">
21100          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
21101          <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>
21102          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
21103          <div style="display:flex;gap:18px;justify-content:flex-end;">
21104            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
21105            <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>
21106          </div>
21107        </div>
21108      </div>
21109      {% if !submodule_rows.is_empty() %}
21110      <div class="submodule-panel">
21111        <div class="toolbar-row">
21112          <div>
21113            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
21114            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
21115          </div>
21116          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
21117        </div>
21118        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
21119        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
21120          <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>
21121          <thead>
21122            <tr>
21123              <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>
21124              <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>
21125              <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>
21126              <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>
21127              <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>
21128              <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>
21129              <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>
21130              <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>
21131            </tr>
21132          </thead>
21133          <tbody>
21134            {% for row in submodule_rows %}
21135            <tr>
21136              <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>
21137              <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>
21138              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
21139              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
21140              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
21141              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
21142              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
21143              <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>
21144            </tr>
21145            {% endfor %}
21146          </tbody>
21147        </table>
21148        </div>
21149      </div>
21150      {% endif %}
21151
21152      <div class="metrics-tables-stack">
21153
21154        <div class="metrics-table-wrap">
21155          <div class="metrics-table-title">Files</div>
21156          <table class="metrics-table">
21157            <thead>
21158              <tr>
21159                <th>Metric</th>
21160                <th>This Run</th>
21161                <th>Previous</th>
21162                <th>Change</th>
21163              </tr>
21164            </thead>
21165            <tbody>
21166              <tr>
21167                <td>Files analyzed</td>
21168                <td class="mt-val-large">{{ files_analyzed }}</td>
21169                <td>{{ prev_fa_str }}</td>
21170                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
21171              </tr>
21172              <tr>
21173                <td>Files skipped</td>
21174                <td>{{ files_skipped }}</td>
21175                <td>{{ prev_fs_str }}</td>
21176                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
21177              </tr>
21178              <tr>
21179                <td>Files modified</td>
21180                <td class="mt-val-na">—</td>
21181                <td class="mt-val-na">—</td>
21182                <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21183              </tr>
21184              <tr>
21185                <td>Files unchanged</td>
21186                <td class="mt-val-na">—</td>
21187                <td class="mt-val-na">—</td>
21188                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21189              </tr>
21190            </tbody>
21191          </table>
21192        </div>
21193
21194        <div class="metrics-table-wrap">
21195          <div class="metrics-table-title">Line Counts</div>
21196          <table class="metrics-table">
21197            <thead>
21198              <tr>
21199                <th>Metric</th>
21200                <th>This Run</th>
21201                <th>Previous</th>
21202                <th>Change</th>
21203              </tr>
21204            </thead>
21205            <tbody>
21206              <tr>
21207                <td>Physical lines</td>
21208                <td class="mt-val-large">{{ physical_lines }}</td>
21209                <td>{{ prev_pl_str }}</td>
21210                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
21211              </tr>
21212              <tr>
21213                <td>Code lines</td>
21214                <td class="mt-val-large">{{ code_lines }}</td>
21215                <td>{{ prev_cl_str }}</td>
21216                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
21217              </tr>
21218              <tr>
21219                <td>Comment lines</td>
21220                <td>{{ comment_lines }}</td>
21221                <td>{{ prev_cml_str }}</td>
21222                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
21223              </tr>
21224              <tr>
21225                <td>Blank lines</td>
21226                <td>{{ blank_lines }}</td>
21227                <td>{{ prev_bl_str }}</td>
21228                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
21229              </tr>
21230              <tr>
21231                <td>Mixed (separate)</td>
21232                <td>{{ mixed_lines }}</td>
21233                <td class="mt-val-na">—</td>
21234                <td class="mt-val-na">—</td>
21235              </tr>
21236            </tbody>
21237          </table>
21238        </div>
21239
21240        <div class="metrics-tables-lower">
21241          <div class="metrics-table-wrap">
21242            <div class="metrics-table-title">Code Structure</div>
21243            <table class="metrics-table">
21244              <thead>
21245                <tr>
21246                  <th>Metric</th>
21247                  <th>This Run</th>
21248                </tr>
21249              </thead>
21250              <tbody>
21251                <tr>
21252                  <td>Functions</td>
21253                  <td>{{ functions }}</td>
21254                </tr>
21255                <tr>
21256                  <td>Classes / Types</td>
21257                  <td>{{ classes }}</td>
21258                </tr>
21259                <tr>
21260                  <td>Variables</td>
21261                  <td>{{ variables }}</td>
21262                </tr>
21263                <tr>
21264                  <td>Imports</td>
21265                  <td>{{ imports }}</td>
21266                </tr>
21267              </tbody>
21268            </table>
21269          </div>
21270
21271          <div class="metrics-table-wrap">
21272            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
21273            <table class="metrics-table">
21274              <thead>
21275                <tr>
21276                  <th>Metric</th>
21277                  <th>Change</th>
21278                </tr>
21279              </thead>
21280              <tbody>
21281                <tr>
21282                  <td>Lines added</td>
21283                  <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21284                </tr>
21285                <tr>
21286                  <td>Lines removed</td>
21287                  <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">&minus;{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21288                </tr>
21289                <tr>
21290                  <td>Lines modified (net)</td>
21291                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
21292                </tr>
21293                <tr>
21294                  <td>Lines unmodified</td>
21295                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21296                </tr>
21297              </tbody>
21298            </table>
21299          </div>
21300        </div>
21301
21302      </div>
21303
21304      <div class="path-list">
21305        <div class="path-item">
21306          <div class="path-item-label">Project path</div>
21307          <code>{{ project_path }}</code>
21308        </div>
21309        <div class="path-item">
21310          <div class="path-item-label">Git branch</div>
21311          {% if let Some(branch) = git_branch %}
21312          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
21313          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
21314          {% else %}
21315          <code style="color:var(--muted)">—</code>
21316          {% endif %}
21317        </div>
21318        <div class="path-item">
21319          <div class="path-item-label">Output folder</div>
21320          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
21321        </div>
21322        <div class="path-item">
21323          <div class="path-item-label">Run ID</div>
21324          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
21325            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
21326            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
21327          </div>
21328        </div>
21329      </div>
21330    </section>
21331
21332    {% if has_cocomo %}
21333    <div class="cocomo-box" style="margin-top:24px;">
21334      <div class="cocomo-box-head">
21335        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
21336        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
21337          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
21338          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
21339        </span>
21340      </div>
21341      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
21342        <div class="stat-chip">
21343          <div class="stat-chip-label">Person-months</div>
21344          <div class="stat-chip-val">{{ cocomo_effort_str }}</div>
21345          <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>
21346        </div>
21347        <div class="stat-chip">
21348          <div class="stat-chip-label">Schedule (months)</div>
21349          <div class="stat-chip-val">{{ cocomo_duration_str }}</div>
21350          <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>
21351        </div>
21352        <div class="stat-chip">
21353          <div class="stat-chip-label">Avg. Team Size</div>
21354          <div class="stat-chip-val">{{ cocomo_staff_str }}</div>
21355          <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>
21356        </div>
21357        <div class="stat-chip">
21358          <div class="stat-chip-label">Input KSLOC</div>
21359          <div class="stat-chip-val">{{ cocomo_ksloc_str }}K</div>
21360          <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>
21361        </div>
21362      </div>
21363      <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>
21364    </div>
21365    {% endif %}
21366
21367    <div class="section-pair">
21368    <section class="panel">
21369        <div class="toolbar-row">
21370          <div>
21371            <h2>Language breakdown</h2>
21372            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
21373          </div>
21374          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21375        </div>
21376        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
21377    </section>
21378
21379    <section class="panel r-chart-section">
21380      <div class="toolbar-row" style="margin-bottom:16px;">
21381        <div>
21382          <h2>Visualizations</h2>
21383          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
21384        </div>
21385      </div>
21386
21387      <div class="r-viz-grid">
21388        <div class="r-viz-card">
21389          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21390            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
21391            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21392          </div>
21393          <div class="r-chart-tab-bar">
21394            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
21395            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
21396          </div>
21397          <div class="r-chart-container" id="r-composition-chart"></div>
21398        </div>
21399        <div class="r-viz-card">
21400          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21401            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
21402            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21403          </div>
21404          <div class="r-chart-container" id="r-scatter-chart"></div>
21405        </div>
21406        {% if has_semantic_data %}
21407        <div class="r-viz-card">
21408          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21409            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
21410            <select class="r-chart-select" id="r-semantic-metric">
21411              <option value="functions">Functions</option>
21412              <option value="classes">Classes</option>
21413              <option value="variables">Variables</option>
21414              <option value="imports">Imports</option>
21415              <option value="tests">Tests</option>
21416            </select>
21417            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21418          </div>
21419          <div class="r-chart-container" id="r-semantic-chart"></div>
21420        </div>
21421        {% endif %}
21422        <div class="r-viz-card">
21423          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21424            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
21425            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21426          </div>
21427          <div class="r-chart-container" id="r-density-chart"></div>
21428        </div>
21429        <div class="r-viz-card">
21430          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21431            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
21432            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21433          </div>
21434          <div class="r-chart-container" id="r-avglines-chart"></div>
21435        </div>
21436        <div class="r-viz-card">
21437          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
21438            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
21439            <select class="r-chart-select" id="r-sub-metric">
21440              <option value="code">Code Lines</option>
21441              <option value="comment">Comments</option>
21442              <option value="blank">Blank Lines</option>
21443              <option value="physical">Physical Lines</option>
21444              <option value="files">Files</option>
21445            </select>
21446            <select class="r-chart-select" id="r-sub-sort">
21447              <option value="desc">Value ↓</option>
21448              <option value="asc">Value ↑</option>
21449              <option value="name">Name A→Z</option>
21450            </select>
21451            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21452          </div>
21453          <div class="r-chart-container" id="r-submodule-chart"></div>
21454        </div>
21455      </div>
21456
21457    </section>
21458    </div>
21459
21460  </div>
21461
21462  <div id="r-tt" aria-hidden="true"></div>
21463
21464  <script nonce="{{ csp_nonce }}">
21465    (function () {
21466      var body = document.body;
21467      var themeToggle = document.getElementById('theme-toggle');
21468      var storageKey = 'oxide-sloc-theme';
21469
21470      function applyTheme(theme) {
21471        body.classList.toggle('dark-theme', theme === 'dark');
21472      }
21473
21474      function loadSavedTheme() {
21475        try {
21476          var saved = localStorage.getItem(storageKey);
21477          if (saved === 'dark' || saved === 'light') {
21478            applyTheme(saved);
21479          }
21480        } catch (e) {}
21481      }
21482
21483      if (themeToggle) {
21484        themeToggle.addEventListener('click', function () {
21485          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
21486          applyTheme(nextTheme);
21487          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
21488        });
21489      }
21490
21491      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
21492        button.addEventListener('click', function () {
21493          var value = button.getAttribute('data-copy-value') || '';
21494          if (!value) return;
21495          var originalText = button.textContent;
21496          function flashSuccess() {
21497            button.textContent = 'Copied!';
21498            setTimeout(function () { button.textContent = originalText; }, 1800);
21499          }
21500          function flashFail() {
21501            button.textContent = 'Copy failed';
21502            setTimeout(function () { button.textContent = originalText; }, 2000);
21503          }
21504          if (navigator.clipboard && navigator.clipboard.writeText) {
21505            navigator.clipboard.writeText(value).then(flashSuccess, function () {
21506              fallbackCopy(value, flashSuccess, flashFail);
21507            });
21508          } else {
21509            fallbackCopy(value, flashSuccess, flashFail);
21510          }
21511        });
21512      });
21513      function fallbackCopy(text, onSuccess, onFail) {
21514        try {
21515          var ta = document.createElement('textarea');
21516          ta.value = text;
21517          ta.style.position = 'fixed';
21518          ta.style.top = '-9999px';
21519          ta.style.left = '-9999px';
21520          document.body.appendChild(ta);
21521          ta.focus();
21522          ta.select();
21523          var ok = document.execCommand('copy');
21524          document.body.removeChild(ta);
21525          if (ok) { onSuccess(); } else { onFail(); }
21526        } catch (e) { onFail(); }
21527      }
21528
21529      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21530        btn.addEventListener('click', function () {
21531          var folder = btn.getAttribute('data-folder') || '';
21532          if (!folder) return;
21533          var orig = btn.textContent;
21534          fetch('/open-path?path=' + encodeURIComponent(folder))
21535            .then(function (r) { return r.json(); })
21536            .then(function (d) {
21537              if (d && d.server_mode_disabled) {
21538                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21539              } else if (d && d.ok) {
21540                btn.textContent = 'Opened!';
21541                setTimeout(function () { btn.textContent = orig; }, 1800);
21542              }
21543            })
21544            .catch(function () {
21545              btn.textContent = 'Failed';
21546              setTimeout(function () { btn.textContent = orig; }, 2000);
21547            });
21548        });
21549      });
21550
21551      loadSavedTheme();
21552
21553      // ── Compact number formatting for stat chips ──────────────────────────
21554      (function(){
21555        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();}
21556        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
21557          var raw=parseInt(chip.getAttribute('data-raw'),10);
21558          if(isNaN(raw))return;
21559          var valEl=chip.querySelector('.stat-chip-val');
21560          if(valEl)valEl.textContent=fmt(raw);
21561          var exactEl=chip.querySelector('.stat-chip-exact');
21562          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
21563        });
21564        // Code density chip
21565        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
21566          var code=parseInt(chip.getAttribute('data-code'),10);
21567          var phys=parseInt(chip.getAttribute('data-physical'),10);
21568          if(isNaN(code)||isNaN(phys)||phys===0)return;
21569          var pct=(code/phys*100).toFixed(1)+'%';
21570          var valEl=chip.querySelector('.stat-chip-val');
21571          if(valEl)valEl.textContent=pct;
21572        });
21573        // Populate author handle from data-author attribute
21574        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
21575          var author=chip.getAttribute('data-author');
21576          var el=chip.querySelector('.author-handle');
21577          if(el)el.textContent='/'+author.replace(/\s+/g,'');
21578        });
21579        // Click-to-copy on run-id-chip elements
21580        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
21581          chip.addEventListener('click',function(){
21582            var val=chip.getAttribute('data-copy');
21583            if(!val)return;
21584            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
21585            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);}
21586            chip.classList.add('chip-copied-flash');
21587            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
21588          });
21589        });
21590      })();
21591
21592      // ── Shared tooltip for all result-page charts ─────────────────────────
21593      var rTT=(function(){
21594        var el=document.getElementById('r-tt');
21595        if(!el)return{s:function(){},h:function(){},m:function(){}};
21596        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
21597        function hide(){el.style.display='none';}
21598        function move(e){
21599          var x=e.clientX+16,y=e.clientY-12;
21600          var r=el.getBoundingClientRect();
21601          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
21602          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
21603          el.style.left=x+'px';el.style.top=y+'px';
21604        }
21605        return{s:show,h:hide,m:move};
21606      })();
21607      window.rTT=rTT;
21608
21609      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
21610      (function(){
21611        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21612        document.addEventListener('mouseover',function(e){
21613          var t=e.target;
21614          while(t&&t.getAttribute){
21615            var l=t.getAttribute('data-ttl');
21616            if(l!==null){
21617              var v=t.getAttribute('data-ttv')||'';
21618              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
21619              return;
21620            }
21621            t=t.parentNode;
21622          }
21623        });
21624        document.addEventListener('mouseout',function(e){
21625          var t=e.target;
21626          while(t&&t.getAttribute){
21627            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
21628            t=t.parentNode;
21629          }
21630        });
21631        document.addEventListener('mousemove',function(e){
21632          var el=document.getElementById('r-tt');
21633          if(el&&el.style.display!=='none')rTT.m(e);
21634        });
21635        window.addEventListener('blur',function(){rTT.h();});
21636        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
21637      })();
21638
21639      // ── Language overview charts ───────────────────────────────────────────
21640      (function(){
21641        var D={{ lang_chart_json|safe }};
21642        if(!D||!D.length)return;
21643        var el=document.getElementById('result-lang-charts');
21644        if(!el)return;
21645        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21646        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
21647        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21648        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();}
21649        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21650        function px(n){return Math.round(n);}
21651        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+'"';}
21652        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
21653
21654        // Donut chart — height matches the stacked-bar chart so both panels align
21655        var rHb_d=28;
21656        var DH=Math.max(220,D.length*rHb_d+32);
21657        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
21658        var legX=204,DW=360;
21659        var legCount=D.length;
21660        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
21661        var legYStart=Math.round((DH-legCount*legSpacing)/2);
21662        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">';
21663        if(D.length===1){
21664          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
21665          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+'"/>';
21666        } else {
21667          var ang=-Math.PI/2;
21668          D.forEach(function(d,i){
21669            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21670            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
21671            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
21672            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
21673            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
21674            var pct=Math.round(d.code/tot*100);
21675            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"/>';
21676            ang+=sw;
21677          });
21678        }
21679        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
21680        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
21681        D.forEach(function(d,i){
21682          var ly=legYStart+i*legSpacing;
21683          var pctL=Math.round(d.code/tot*100);
21684          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
21685          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
21686          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
21687          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
21688          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
21689          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
21690          ds+='</g>';
21691        });
21692        ds+='</svg>';
21693
21694        // Horizontal stacked-bar chart — fills container width
21695        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
21696        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
21697        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">';
21698        D.forEach(function(d,i){
21699          var y=6+i*rHb,x=LW;
21700          var phys=d.physical||d.code+d.comments+d.blanks;
21701          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
21702          bs+='<g class="lang-bar-row">';
21703          bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
21704          bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
21705          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="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
21706          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="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
21707          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="'+bH+'" fill="'+GY+'" rx="0"/>';
21708          bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
21709          bs+='</g>';
21710        });
21711        var ly=SH-14;
21712        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
21713        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
21714        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
21715        var totAll=totC+totCm+totBl||1;
21716        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
21717        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
21718        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
21719        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
21720        bs+='<g data-kind="code" style="cursor:pointer;">'
21721          +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
21722          +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
21723          +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
21724          +'</g>';
21725        bs+='<g data-kind="comment" style="cursor:pointer;">'
21726          +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
21727          +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
21728          +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
21729          +'</g>';
21730        bs+='<g data-kind="blank" style="cursor:pointer;">'
21731          +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
21732          +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
21733          +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
21734          +'</g>';
21735        bs+='</svg>';
21736        el.innerHTML='<div class="r-lang-overview">'+
21737          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
21738          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
21739        '</div>';
21740        function wireDonutLegend(svg){
21741          if(!svg)return;
21742          var paths=svg.querySelectorAll('path[data-lang]');
21743          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';}}}
21744          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
21745          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;}});
21746          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
21747        }
21748        function wireMixLegend(svg){
21749          if(!svg)return;
21750          var legGs=svg.querySelectorAll('g[data-kind]');
21751          var allRects=svg.querySelectorAll('rect[data-kind]');
21752          if(!legGs.length)return;
21753          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';}}
21754          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='';}}
21755          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]);}
21756        }
21757        wireDonutLegend(el.querySelector('svg'));
21758        wireMixLegend(el.querySelectorAll('svg')[1]);
21759
21760        // ── Language breakdown Full View expand ─────────────────────────────────
21761        var langOvBtn=document.getElementById('result-lang-overview-expand');
21762        if(langOvBtn){langOvBtn.addEventListener('click',function(){
21763          var src=document.getElementById('result-lang-charts');
21764          if(!src)return;
21765          var overlay=document.createElement('div');
21766          overlay.className='r-chart-modal-overlay';
21767          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 — Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
21768          document.body.appendChild(overlay);
21769          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21770          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21771          var wrap=document.getElementById('result-lang-overview-modal-wrap');
21772          if(wrap){
21773            wrap.innerHTML=src.innerHTML;
21774            var svgs=wrap.querySelectorAll('svg');
21775            for(var i=0;i<svgs.length;i++){
21776              svgs[i].removeAttribute('width');
21777              svgs[i].removeAttribute('height');
21778              svgs[i].style.cssText='display:block;width:100%;height:auto;';
21779            }
21780            var ov=wrap.querySelector('.r-lang-overview');
21781            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
21782            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
21783            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
21784            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
21785            wireDonutLegend(wrap.querySelector('svg'));
21786            wireMixLegend(wrap.querySelectorAll('svg')[1]);
21787            requestAnimationFrame(function(){
21788              var ss=wrap.querySelectorAll('svg');
21789              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%;';}}
21790            });
21791          }
21792        });}
21793      })();
21794
21795      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
21796      (function(){
21797        var LANG_D={{ lang_chart_json|safe }};
21798        var SCAT_D={{ scatter_chart_json|safe }};
21799        var SEM_D={{ semantic_chart_json|safe }};
21800        var SUB_D={{ submodule_chart_json|safe }};
21801        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
21802        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21803        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();}
21804        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21805        function px(n){return Math.round(n);}
21806        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+'"';}
21807
21808        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
21809        function renderCompositionInEl(el,mode,shOvr){
21810          if(!el||!LANG_D||!LANG_D.length)return;
21811          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21812          var LW=110,SH=shOvr||224;
21813          var svgW=Math.max(320,el.offsetWidth||480);
21814          var BW=Math.max(120,svgW-LW-80);
21815          var legendH=24,topPad=4;
21816          var n=LANG_D.length||1;
21817          var rowTotal=Math.floor((SH-legendH-topPad)/n);
21818          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
21819          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">';
21820          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
21821          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
21822          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
21823          var totAll2=totC2+totCm2+totBl2||1;
21824          if(mode==='pct'){
21825            LANG_D.forEach(function(d,i){
21826              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
21827              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
21828              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21829              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>';
21830              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+'"/>';x+=cW;
21831              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+'"/>';x+=cmW;
21832              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+'"/>';
21833              var pct=Math.round((d.code||0)/tot2*100);
21834              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+pct+'%</text>';
21835            });
21836          } else {
21837            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
21838            LANG_D.forEach(function(d,i){
21839              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
21840              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21841              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>';
21842              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+'"/>';x+=cW;
21843              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+'"/>';x+=cmW;
21844              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+'"/>';
21845              s+='<text x="'+(LW+cW+cmW+blW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
21846            });
21847          }
21848          var ly=SH-legendH+4;
21849          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
21850          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
21851          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
21852          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
21853          s+='<g data-kind="code" style="cursor:pointer;">'
21854            +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
21855            +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
21856            +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
21857            +'</g>';
21858          s+='<g data-kind="comment" style="cursor:pointer;">'
21859            +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
21860            +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
21861            +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
21862            +'</g>';
21863          s+='<g data-kind="blank" style="cursor:pointer;">'
21864            +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
21865            +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
21866            +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
21867            +'</g>';
21868          s+='</svg>';
21869          el.innerHTML=s;
21870          wireMixLegendEl(el);
21871        }
21872        function wireMixLegendEl(container){
21873          var svg=container&&container.querySelector('svg');
21874          if(!svg)return;
21875          var legGs=svg.querySelectorAll('g[data-kind]');
21876          var allRects=svg.querySelectorAll('rect[data-kind]');
21877          if(!legGs.length)return;
21878          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';}}
21879          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='';}}
21880          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]);}
21881        }
21882        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
21883        renderComposition('abs');
21884        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
21885          btn.addEventListener('click',function(){
21886            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
21887            btn.classList.add('active');
21888            renderComposition(btn.getAttribute('data-rcomp'));
21889          });
21890        });
21891
21892        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
21893        function renderScatterInEl(el,hOvr){
21894          if(!el||!SCAT_D||!SCAT_D.length)return;
21895          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
21896          var W=Math.max(320,el.offsetWidth||480);
21897          var cW=W-PL-PR,cH=H-PT-PB;
21898          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
21899          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
21900          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
21901          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">';
21902          [0,0.25,0.5,0.75,1].forEach(function(t){
21903            var y=PT+cH*(1-t);
21904            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21905            if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';
21906          });
21907          [0,0.25,0.5,0.75,1].forEach(function(t){
21908            var x=PL+cW*t;
21909            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21910            if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';
21911          });
21912          SCAT_D.forEach(function(d,i){
21913            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
21914            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
21915            s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
21916            if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';
21917          });
21918          s+='<text x="'+(PL+cW/2)+'" y="'+(H-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7">Files</text>';
21919          s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
21920          s+='</svg>';
21921          el.innerHTML=s;
21922        }
21923        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
21924
21925        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
21926        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
21927        // the old vertical column layout on wide containers.
21928        function renderSemanticInEl(el,key,sh){
21929          if(!el||!SEM_D||!SEM_D.length)return;
21930          var n2=SEM_D.length||1;
21931          var LW=112,SH=sh||Math.max(180,n2*28+26);
21932          var svgW=Math.max(320,el.offsetWidth||480);
21933          var BW=Math.max(120,svgW-LW-80);
21934          var topPad=4,botPad=14;
21935          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
21936          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
21937          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
21938          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">';
21939          SEM_D.forEach(function(d,i){
21940            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
21941            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>';
21942            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"/>';
21943            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>';
21944          });
21945          s+='</svg>';
21946          el.innerHTML=s;
21947        }
21948        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
21949        var semSel=document.getElementById('r-semantic-metric');
21950        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
21951        var semExpand=document.getElementById('r-semantic-expand');
21952        if(semExpand){
21953          semExpand.addEventListener('click',function(){
21954            var key=semSel?semSel.value:'functions';
21955            var n=SEM_D.length||1;
21956            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
21957            var modalH=Math.min(Math.max(360,n*38+60),maxH);
21958            var overlay=document.createElement('div');
21959            overlay.className='r-chart-modal-overlay';
21960            var optHtml=
21961              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
21962              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
21963              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
21964              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
21965              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
21966            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 — Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
21967            document.body.appendChild(overlay);
21968            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21969            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21970            var modalEl=document.getElementById('r-sem-modal-chart');
21971            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
21972            var modalSel=document.getElementById('r-sem-modal-metric');
21973            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
21974          });
21975        }
21976
21977        // ── Expand buttons: re-render charts at large size inside modal ──────────
21978        (function(){
21979          function makeExpandModal(title,mH,subtitle,ctrlHtml){
21980            var overlay=document.createElement('div');
21981            overlay.className='r-chart-modal-overlay';
21982            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
21983            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
21984            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>';
21985            document.body.appendChild(overlay);
21986            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21987            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21988            return overlay.querySelector('.r-expand-modal-chart');
21989          }
21990          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
21991          var compExpandBtn=document.getElementById('r-composition-expand');
21992          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
21993            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
21994            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21995            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
21996              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
21997            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
21998            if(wrap){
21999              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
22000              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
22001                btn.addEventListener('click',function(){
22002                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
22003                  btn.classList.add('active');
22004                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
22005                });
22006              });
22007            }
22008          });}
22009          var scatExpandBtn=document.getElementById('r-scatter-expand');
22010          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
22011            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
22012            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
22013          });}
22014          var densExpandBtn=document.getElementById('r-density-expand');
22015          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
22016            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
22017            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
22018            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
22019          });}
22020          var avgExpandBtn=document.getElementById('r-avglines-expand');
22021          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
22022            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
22023            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
22024            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
22025          });}
22026          var subExpandBtn=document.getElementById('r-submodule-expand');
22027          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
22028            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
22029            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
22030            var metCtrl=
22031              '<select class="r-chart-select" id="r-sub-modal-metric">'
22032              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
22033              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
22034              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
22035              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
22036              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
22037              +'</select>';
22038            var sortCtrl=
22039              '<select class="r-chart-select" id="r-sub-modal-sort">'
22040              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
22041              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
22042              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
22043              +'</select>';
22044            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
22045            if(wrap){
22046              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
22047              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
22048              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
22049              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
22050              if(mSub)mSub.addEventListener('change',reRenderSub);
22051              if(mSort)mSort.addEventListener('change',reRenderSub);
22052            }
22053          });}
22054        })();
22055
22056        // ── Comment Density: comments / (code + comments) per language ───────────
22057        function renderDensityInEl(el,shOvr){
22058          if(!el||!LANG_D||!LANG_D.length)return;
22059          var n=LANG_D.length||1;
22060          var LW=112,SH=shOvr||Math.max(180,n*28+26);
22061          var svgW=Math.max(320,el.offsetWidth||480);
22062          var BW=Math.max(120,svgW-LW-80);
22063          var topPad=4,botPad=26;
22064          var rowTotal=Math.floor((SH-topPad-botPad)/n);
22065          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22066          var densities=LANG_D.map(function(d){
22067            var sig=(d.code||0)+(d.comments||0);
22068            return sig>0?(d.comments||0)/sig:0;
22069          });
22070          var maxDen=Math.max.apply(null,densities)||1;
22071          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">';
22072          LANG_D.forEach(function(d,i){
22073            var den=densities[i],bw=den/maxDen*BW;
22074            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22075            var pct=Math.round(den*100);
22076            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>';
22077            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"/>';
22078            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22079            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>';
22080          });
22081          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>';
22082          s+='</svg>';
22083          el.innerHTML=s;
22084        }
22085        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
22086        renderDensity();
22087
22088        // ── Avg Lines per File: code / files per language ─────────────────────
22089        function renderAvgLinesInEl(el,shOvr){
22090          if(!el||!LANG_D||!LANG_D.length)return;
22091          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
22092          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
22093          var n=data.length||1;
22094          var LW=112,SH=shOvr||Math.max(180,n*28+26);
22095          var svgW=Math.max(320,el.offsetWidth||480);
22096          var BW=Math.max(120,svgW-LW-80);
22097          var topPad=4,botPad=26;
22098          var rowTotal=Math.floor((SH-topPad-botPad)/n);
22099          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22100          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
22101          var maxAvg=Math.max.apply(null,avgs)||1;
22102          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">';
22103          data.forEach(function(d,i){
22104            var avg=avgs[i],bw=avg/maxAvg*BW;
22105            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22106            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>';
22107            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
22108            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22109            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>';
22110          });
22111          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>';
22112          s+='</svg>';
22113          el.innerHTML=s;
22114        }
22115        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
22116        renderAvgLines();
22117
22118        // ── Repository Overview: overall row + per-submodule rows ────────────
22119        function renderSubmoduleInEl(el,key,sort,shOvr){
22120          if(!el)return;
22121          var overall={
22122            name:'Overall',
22123            code:{{ code_lines }},
22124            comment:{{ comment_lines }},
22125            blank:{{ blank_lines }},
22126            physical:{{ physical_lines }},
22127            files:{{ files_analyzed }},
22128            isOverall:true
22129          };
22130          var subs=SUB_D.slice();
22131          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
22132          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
22133          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
22134          var data=[overall].concat(subs);
22135          var rowH=32,bH=22,sepH=subs.length>0?14:0;
22136          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
22137          var svgW=Math.max(320,el.offsetWidth||480);
22138          var LW=116,BW=Math.max(200,svgW-LW-54);
22139          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
22140          var OVERALL_COL='#6b7280';
22141          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">';
22142          var yOff=4;
22143          data.forEach(function(d,i){
22144            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
22145            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
22146            var label=d.name||d.path||'?';
22147            s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
22148            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
22149            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22150            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
22151            yOff+=rowH;
22152            if(d.isOverall&&subs.length>0){
22153              yOff+=sepH;
22154            }
22155          });
22156          s+='</svg>';
22157          el.innerHTML=s;
22158        }
22159        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
22160        var subSel=document.getElementById('r-sub-metric');
22161        var sortSel=document.getElementById('r-sub-sort');
22162        renderSubmodule('code','desc');
22163        if(subSel){
22164          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
22165          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
22166        }
22167
22168        // Equalise heights within each chart row: if one chart in a grid row is taller
22169        // than its neighbour, re-render the shorter one at the taller height so bars fill
22170        // the available vertical space instead of leaving a gap.
22171        function syncRowHeights(){
22172          var avgEl=document.getElementById('r-avglines-chart');
22173          var subEl=document.getElementById('r-submodule-chart');
22174          if(avgEl&&subEl){
22175            var avgSvg=avgEl.querySelector('svg');
22176            var subSvg=subEl.querySelector('svg');
22177            if(avgSvg&&subSvg){
22178              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
22179              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
22180              var key=subSel?subSel.value||'code':'code';
22181              var sort=sortSel?sortSel.value:'desc';
22182              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
22183              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
22184            }
22185          }
22186          var semEl=document.getElementById('r-semantic-chart');
22187          var denEl=document.getElementById('r-density-chart');
22188          if(semEl&&denEl){
22189            var semSvg=semEl.querySelector('svg');
22190            var denSvg=denEl.querySelector('svg');
22191            if(semSvg&&denSvg){
22192              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
22193              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
22194              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
22195              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
22196            }
22197          }
22198        }
22199        syncRowHeights();
22200
22201        // Re-render all SVG charts when the window is resized so bars fill the card.
22202        var _rResizeTimer;
22203        window.addEventListener('resize',function(){
22204          clearTimeout(_rResizeTimer);
22205          _rResizeTimer=setTimeout(function(){
22206            var rcompBtn=document.querySelector('[data-rcomp].active');
22207            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
22208            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
22209            if(semSel)renderSemantic(semSel.value||'functions');
22210            renderDensity();
22211            renderAvgLines();
22212            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
22213            syncRowHeights();
22214          },120);
22215        });
22216      })();
22217
22218      (function randomizeWatermarks() {
22219        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22220        if (!wms.length) return;
22221        var placed = [];
22222        function tooClose(top, left) {
22223          for (var i = 0; i < placed.length; i++) {
22224            var dt = Math.abs(placed[i][0] - top);
22225            var dl = Math.abs(placed[i][1] - left);
22226            if (dt < 20 && dl < 18) return true;
22227          }
22228          return false;
22229        }
22230        function pick(leftBand) {
22231          for (var attempt = 0; attempt < 50; attempt++) {
22232            var top = Math.random() * 85 + 5;
22233            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22234            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22235          }
22236          var top = Math.random() * 85 + 5;
22237          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22238          placed.push([top, left]);
22239          return [top, left];
22240        }
22241        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
22242        var half = Math.floor(wms.length / 2);
22243        wms.forEach(function (img, i) {
22244          var pos = pick(i < half);
22245          var size = Math.floor(Math.random() * 100 + 160);
22246          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
22247          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
22248          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;
22249        });
22250      })();
22251
22252      (function spawnCodeParticles() {
22253        var container = document.getElementById('code-particles');
22254        if (!container) return;
22255        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'];
22256        for (var i = 0; i < 38; i++) {
22257          (function(idx) {
22258            var el = document.createElement('span');
22259            el.className = 'code-particle';
22260            el.textContent = snippets[idx % snippets.length];
22261            var left = Math.random() * 94 + 2;
22262            var top = Math.random() * 88 + 6;
22263            var dur = (Math.random() * 10 + 9).toFixed(1);
22264            var delay = (Math.random() * 18).toFixed(1);
22265            var rot = (Math.random() * 26 - 13).toFixed(1);
22266            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22267            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';
22268            container.appendChild(el);
22269          })(i);
22270        }
22271      })();
22272
22273      {% if pdf_generating %}
22274      // Poll for PDF readiness and swap the disabled button to a live link once done.
22275      (function() {
22276        var openBtn = document.getElementById('pdf-open-btn');
22277        var dlBtn = document.getElementById('pdf-download-btn');
22278        function checkPdf() {
22279          fetch('/api/runs/{{ run_id }}/pdf-status')
22280            .then(function(r) { return r.json(); })
22281            .then(function(d) {
22282              if (d.ready) {
22283                if (openBtn) {
22284                  var a = document.createElement('a');
22285                  a.className = 'button';
22286                  a.id = 'pdf-open-btn';
22287                  a.href = '/runs/pdf/{{ run_id }}';
22288                  a.target = '_blank';
22289                  a.rel = 'noopener';
22290                  a.textContent = 'Open PDF';
22291                  openBtn.replaceWith(a);
22292                }
22293                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
22294              } else {
22295                setTimeout(checkPdf, 3000);
22296              }
22297            })
22298            .catch(function() { setTimeout(checkPdf, 5000); });
22299        }
22300        setTimeout(checkPdf, 3000);
22301      })();
22302      {% endif %}
22303
22304    })();
22305  </script>
22306  <script nonce="{{ csp_nonce }}">
22307  (function(){
22308    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'}];
22309    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);});}
22310    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22311    function init(){
22312      var btn=document.getElementById('settings-btn');if(!btn)return;
22313      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22314      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>';
22315      document.body.appendChild(m);
22316      var g=document.getElementById('scheme-grid');
22317      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);});
22318      var cl=document.getElementById('settings-close');
22319      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);
22320      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');});
22321      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22322      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22323    }
22324    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22325  }());
22326  </script>
22327  <footer class="site-footer">
22328    local code analysis - metrics, history and reports
22329    &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>
22330    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22331    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22332    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22333    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22334  </footer>
22335  {% if confluence_configured %}
22336  <script nonce="{{ csp_nonce }}">
22337  (function() {
22338    var postBtn = document.getElementById('postConfluenceBtn');
22339    var copyBtn = document.getElementById('copyWikiBtn');
22340    var modal   = document.getElementById('confluenceModal');
22341    if (!postBtn || !modal) return;
22342
22343    postBtn.addEventListener('click', function() {
22344      document.getElementById('confStatus').style.display = 'none';
22345      modal.style.display = 'flex';
22346    });
22347    document.getElementById('confCancelBtn').addEventListener('click', function() {
22348      modal.style.display = 'none';
22349    });
22350    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22351
22352    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
22353      var btn = this;
22354      btn.disabled = true;
22355      var status = document.getElementById('confStatus');
22356      status.style.display = 'block';
22357      status.style.background = '#dbeafe';
22358      status.style.color = '#1e40af';
22359      status.textContent = 'Posting to Confluence…';
22360      var resp = await fetch('/api/confluence/post', {
22361        method: 'POST',
22362        headers: { 'Content-Type': 'application/json' },
22363        body: JSON.stringify({
22364          run_id: '{{ run_id }}',
22365          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
22366          report_url: document.getElementById('confReportUrl').value.trim() || null
22367        })
22368      });
22369      var data = await resp.json();
22370      if (data.ok) {
22371        status.style.background = '#dcfce7'; status.style.color = '#166534';
22372        status.textContent = 'Posted! Page ID: ' + data.page_id;
22373      } else {
22374        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22375        status.textContent = 'Error: ' + (data.error || 'Unknown error');
22376      }
22377      btn.disabled = false;
22378    });
22379
22380    if (copyBtn) {
22381      copyBtn.addEventListener('click', async function() {
22382        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
22383        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
22384        var text = await resp.text();
22385        try {
22386          await navigator.clipboard.writeText(text);
22387          var orig = copyBtn.textContent;
22388          copyBtn.textContent = 'Copied!';
22389          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
22390        } catch(e) {
22391          alert('Clipboard write failed — check browser permissions.');
22392        }
22393      });
22394    }
22395  })();
22396  </script>
22397  {% endif %}
22398  <script nonce="{{ csp_nonce }}">
22399  (function() {
22400    var deleteBtn = document.getElementById('delete-run-btn');
22401    var modal     = document.getElementById('delete-run-modal');
22402    var cancelBtn = document.getElementById('delete-run-cancel');
22403    var confirmBtn= document.getElementById('delete-run-confirm');
22404    if (!deleteBtn || !modal) return;
22405    deleteBtn.addEventListener('click', function() {
22406      document.getElementById('delete-run-status').style.display = 'none';
22407      modal.style.display = 'flex';
22408    });
22409    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
22410    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22411    confirmBtn.addEventListener('click', async function() {
22412      confirmBtn.disabled = true;
22413      cancelBtn.disabled = true;
22414      var status = document.getElementById('delete-run-status');
22415      status.style.display = 'block';
22416      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
22417      status.textContent = 'Deleting…';
22418      try {
22419        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
22420        if (resp.status === 204 || resp.ok) {
22421          status.style.background = '#dcfce7'; status.style.color = '#166534';
22422          status.textContent = 'Deleted. Redirecting…';
22423          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
22424        } else {
22425          var d = await resp.json().catch(function(){return {};});
22426          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22427          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
22428          confirmBtn.disabled = false;
22429          cancelBtn.disabled = false;
22430        }
22431      } catch (e) {
22432        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22433        status.textContent = 'Network error: ' + String(e);
22434        confirmBtn.disabled = false;
22435        cancelBtn.disabled = false;
22436      }
22437    });
22438  })();
22439  </script>
22440  <script nonce="{{ csp_nonce }}">(function(){
22441    var bundleBtn = document.getElementById('download-bundle-btn');
22442    if (bundleBtn) {
22443      bundleBtn.addEventListener('click', function() {
22444        bundleBtn.disabled = true;
22445        var orig = bundleBtn.textContent;
22446        bundleBtn.textContent = 'Preparing…';
22447        fetch('/api/runs/{{ run_id }}/bundle')
22448          .then(function(r) {
22449            if (!r.ok) throw new Error('HTTP ' + r.status);
22450            return r.blob();
22451          })
22452          .then(function(blob) {
22453            var url = URL.createObjectURL(blob);
22454            var a = document.createElement('a');
22455            a.href = url;
22456            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
22457            document.body.appendChild(a);
22458            a.click();
22459            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
22460            bundleBtn.disabled = false;
22461            bundleBtn.textContent = orig;
22462          })
22463          .catch(function(e) {
22464            bundleBtn.disabled = false;
22465            bundleBtn.textContent = orig;
22466            alert('Bundle download failed: ' + String(e));
22467          });
22468      });
22469    }
22470  })();</script>
22471  <script nonce="{{ csp_nonce }}">(function(){
22472    var dot=document.getElementById('status-dot');
22473    var pingEl=document.getElementById('server-ping-ms');
22474    var tipEl=document.getElementById('server-tip-ping');
22475    var fm=document.getElementById('footer-mode');
22476    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)';}}
22477    function doPing(){
22478      var t0=performance.now();
22479      fetch('/healthz',{cache:'no-store'})
22480        .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);})
22481        .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)';}});
22482    }
22483    doPing();
22484    setInterval(doPing,5000);
22485    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
22486  })();</script>
22487  {% if let Some(banner) = report_header_footer %}
22488  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
22489  {% endif %}
22490</body>
22491</html>
22492"##,
22493    ext = "html"
22494)]
22495// Template structs need many bool fields to pass Askama rendering flags.
22496#[allow(clippy::struct_excessive_bools)]
22497struct ResultTemplate {
22498    version: &'static str,
22499    report_title: String,
22500    project_path: String,
22501    output_dir: String,
22502    run_id: String,
22503    files_analyzed: u64,
22504    files_skipped: u64,
22505    physical_lines: u64,
22506    code_lines: u64,
22507    comment_lines: u64,
22508    blank_lines: u64,
22509    mixed_lines: u64,
22510    functions: u64,
22511    classes: u64,
22512    variables: u64,
22513    imports: u64,
22514    html_url: Option<String>,
22515    pdf_url: Option<String>,
22516    json_url: Option<String>,
22517    html_download_url: Option<String>,
22518    pdf_download_url: Option<String>,
22519    json_download_url: Option<String>,
22520    html_path: Option<String>,
22521    json_path: Option<String>,
22522    prev_run_id: Option<String>,
22523    prev_run_timestamp: Option<String>,
22524    prev_run_code_lines: Option<u64>,
22525    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
22526    prev_fa_str: String,
22527    prev_fs_str: String,
22528    prev_pl_str: String,
22529    prev_cl_str: String,
22530    prev_cml_str: String,
22531    prev_bl_str: String,
22532    // Signed change column for main metrics
22533    delta_fa_str: String,
22534    delta_fa_class: String,
22535    delta_fs_str: String,
22536    delta_fs_class: String,
22537    delta_pl_str: String,
22538    delta_pl_class: String,
22539    delta_cl_str: String,
22540    delta_cl_class: String,
22541    delta_cml_str: String,
22542    delta_cml_class: String,
22543    delta_bl_str: String,
22544    delta_bl_class: String,
22545    // delta vs previous scan
22546    delta_lines_added: Option<i64>,
22547    delta_lines_removed: Option<i64>,
22548    delta_lines_net_str: String,
22549    delta_lines_net_class: String,
22550    delta_files_added: Option<usize>,
22551    delta_files_removed: Option<usize>,
22552    delta_files_modified: Option<usize>,
22553    delta_files_unchanged: Option<usize>,
22554    delta_unmodified_lines: Option<u64>,
22555    // git context
22556    git_branch: Option<String>,
22557    git_branch_url: Option<String>,
22558    git_commit: Option<String>,
22559    git_commit_long: Option<String>,
22560    git_author: Option<String>,
22561    git_commit_url: Option<String>,
22562    // scan metadata for hero section
22563    scan_performed_by: String,
22564    scan_time_display: String,
22565    os_display: String,
22566    test_count: u64,
22567    // history
22568    prev_scan_count: usize,
22569    current_scan_number: usize,
22570    // submodule breakdown (empty when not requested)
22571    submodule_rows: Vec<SubmoduleRow>,
22572    scan_config_url: String,
22573    lang_chart_json: String,
22574    // Askama reads these via proc-macro expansion; clippy can't trace through it.
22575    #[allow(dead_code)]
22576    scatter_chart_json: String,
22577    #[allow(dead_code)]
22578    semantic_chart_json: String,
22579    #[allow(dead_code)]
22580    submodule_chart_json: String,
22581    #[allow(dead_code)]
22582    has_submodule_data: bool,
22583    #[allow(dead_code)]
22584    has_semantic_data: bool,
22585    pdf_generating: bool,
22586    csp_nonce: String,
22587    /// Whether Confluence integration is configured — shows Post button when true.
22588    confluence_configured: bool,
22589    server_mode: bool,
22590    /// Header/footer identification banner, mirrored from the HTML/PDF report.
22591    report_header_footer: Option<String>,
22592    run_id_short: String,
22593    /// True when rendering a static offline file (index.html); hides server-only actions.
22594    #[allow(dead_code)]
22595    is_offline: bool,
22596    /// Total cyclomatic complexity score across all analyzed files.
22597    cyclomatic_complexity: u64,
22598    /// Logical SLOC (statement count) when available; None for unsupported languages.
22599    lsloc: Option<u64>,
22600    /// Unique Lines of Code across all analyzed files.
22601    uloc: u64,
22602    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
22603    dryness_pct_str: String,
22604    /// Number of duplicate file groups detected.
22605    duplicate_group_count: usize,
22606    /// Whether a COCOMO estimate is available to display.
22607    has_cocomo: bool,
22608    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
22609    cocomo_effort_str: String,
22610    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
22611    cocomo_duration_str: String,
22612    /// Pre-formatted average team size, e.g. "2.32".
22613    cocomo_staff_str: String,
22614    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
22615    cocomo_ksloc_str: String,
22616    /// COCOMO mode label shown in the card (e.g. "Organic").
22617    cocomo_mode_label: String,
22618    /// Tooltip text explaining the selected COCOMO mode.
22619    cocomo_mode_tooltip: String,
22620    /// Per-file complexity alert threshold. 0 = off (no highlighting).
22621    complexity_alert: u32,
22622}
22623
22624#[derive(Template)]
22625#[template(
22626    source = r##"
22627<!doctype html>
22628<html lang="en">
22629<head>
22630  <meta charset="utf-8">
22631  <meta name="viewport" content="width=device-width, initial-scale=1">
22632  <title>OxideSLOC | Analyzing…</title>
22633  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22634  <style nonce="{{ csp_nonce }}">
22635    :root {
22636      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22637      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22638      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22639      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22640    }
22641    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22642    *{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;}
22643    .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);}
22644    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22645    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
22646    .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));}
22647    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22648    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22649    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22650    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22651    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22652    @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; } }
22653    .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;}
22654    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22655    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22656    .page-body{padding:32px 24px 36px;}
22657    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
22658    .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;}
22659    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
22660    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
22661    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
22662    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
22663    .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;}
22664    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
22665    .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;}
22666    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
22667    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
22668    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
22669    .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;}
22670    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
22671    .hidden{display:none!important;}
22672    .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;}
22673    .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;}
22674    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
22675    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
22676    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
22677    .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);}
22678    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
22679    .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;}
22680    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
22681    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22682    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22683    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
22684    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22685    .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;}
22686    @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));}}
22687    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22688    .site-footer a{color:var(--muted);}
22689    .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;}
22690    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
22691    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
22692    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
22693  </style>
22694</head>
22695<body>
22696  <div class="background-watermarks" aria-hidden="true">
22697    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22698    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22699    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22700    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22701    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22702    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22703  </div>
22704  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22705  <nav class="top-nav">
22706    <div class="top-nav-inner">
22707      <a href="/" class="brand">
22708        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
22709        <div class="brand-copy">
22710          <h1 class="brand-title">OxideSLOC</h1>
22711          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
22712        </div>
22713      </a>
22714      <div class="nav-right">
22715        <a class="nav-pill" href="/">Home</a>
22716        <div class="nav-dropdown">
22717          <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>
22718          <div class="nav-dropdown-menu">
22719            <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>
22720          </div>
22721        </div>
22722        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22723        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22724        <div class="nav-dropdown">
22725          <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>
22726          <div class="nav-dropdown-menu">
22727            <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>
22728          </div>
22729        </div>
22730        <div class="server-status-wrap" id="server-status-wrap">
22731          <div class="nav-pill server-online-pill" id="server-status-pill">
22732            <span class="status-dot" id="status-dot"></span>
22733            <span id="server-status-label">Server</span>
22734            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22735          </div>
22736          <div class="server-status-tip">
22737            OxideSLOC is running — accessible on your network.
22738            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22739          </div>
22740        </div>
22741        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22742          <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>
22743        </button>
22744        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22745          <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>
22746          <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>
22747        </button>
22748      </div>
22749    </div>
22750  </nav>
22751  <div class="page-body">
22752    <div class="wait-panel">
22753      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
22754      <h2 class="wait-title">Analyzing your project…</h2>
22755      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
22756      <div class="path-block">{{ project_path }}</div>
22757      <div class="metrics-row">
22758        <div class="metric-card">
22759          <div class="metric-label">Elapsed</div>
22760          <div class="metric-value" id="elapsed">0s</div>
22761        </div>
22762        <div class="metric-card">
22763          <div class="metric-label">Phase</div>
22764          <div class="metric-value" id="phase">Starting</div>
22765        </div>
22766        <div class="metric-card hidden" id="files-card">
22767          <div class="metric-label">Files</div>
22768          <div class="metric-value" id="files-progress">0</div>
22769        </div>
22770      </div>
22771      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
22772      <div class="warn-slow hidden" id="warn-slow">
22773        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.
22774      </div>
22775      <div class="err-panel hidden" id="err-panel">
22776        <strong>Analysis failed</strong>
22777        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
22778      </div>
22779      <div class="actions hidden" id="actions">
22780        <a href="/scan" class="btn-primary">Try Again</a>
22781        <a href="/view-reports" class="btn-outline">View Reports</a>
22782      </div>
22783    </div>
22784  </div>
22785  <script nonce="{{ csp_nonce }}">
22786    (function() {
22787      var WAIT_ID = {{ wait_id_json|safe }};
22788      var startTime = Date.now();
22789      var pollInterval = 1500;
22790      var retries = 0;
22791      var maxRetries = 5;
22792      var warnShown = false;
22793
22794      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();}
22795
22796      function elapsed() {
22797        return Math.floor((Date.now() - startTime) / 1000);
22798      }
22799
22800      function updateElapsed() {
22801        var s = elapsed();
22802        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
22803      }
22804
22805      function setPhase(txt) {
22806        document.getElementById('phase').textContent = txt;
22807      }
22808
22809      var elapsedTimer = setInterval(updateElapsed, 1000);
22810
22811      function poll() {
22812        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
22813          .then(function(r) {
22814            if (!r.ok) throw new Error('HTTP ' + r.status);
22815            return r.json();
22816          })
22817          .then(function(data) {
22818            retries = 0;
22819            if (data.state === 'complete') {
22820              clearInterval(elapsedTimer);
22821              setPhase('Done');
22822              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
22823            } else if (data.state === 'failed') {
22824              clearInterval(elapsedTimer);
22825              setPhase('Failed');
22826              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
22827              document.getElementById('err-panel').classList.remove('hidden');
22828              document.getElementById('actions').classList.remove('hidden');
22829            } else {
22830              // still running
22831              var s = elapsed();
22832              if (s > 90 && !warnShown) {
22833                warnShown = true;
22834                document.getElementById('warn-slow').classList.remove('hidden');
22835              }
22836              setPhase(data.phase || 'Running');
22837              var fd = data.files_done || 0, ft = data.files_total || 0;
22838              if (ft > 0) {
22839                var card = document.getElementById('files-card');
22840                if (card) card.classList.remove('hidden');
22841                var fp = document.getElementById('files-progress');
22842                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
22843              }
22844              setTimeout(poll, pollInterval);
22845            }
22846          })
22847          .catch(function(err) {
22848            retries++;
22849            if (retries >= maxRetries) {
22850              clearInterval(elapsedTimer);
22851              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
22852              document.getElementById('err-panel').classList.remove('hidden');
22853              document.getElementById('actions').classList.remove('hidden');
22854            } else {
22855              // exponential back-off capped at 8s
22856              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
22857            }
22858          });
22859      }
22860
22861      setTimeout(poll, pollInterval);
22862
22863      // If the browser restores this page from bfcache (Back after viewing results),
22864      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
22865      window.addEventListener("pageshow", function(e) {
22866        if (e.persisted) { setTimeout(poll, 200); }
22867      });
22868    })();
22869  </script>
22870  <footer class="site-footer">
22871    local code analysis - metrics, history and reports
22872    &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>
22873    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22874    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22875    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22876    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22877  </footer>
22878  <script nonce="{{ csp_nonce }}">
22879    (function(){
22880      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
22881      if(s==="dark")b.classList.add("dark-theme");
22882      var tt=document.getElementById("theme-toggle");
22883      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
22884    })();
22885    (function spawnCodeParticles(){
22886      var c=document.getElementById('code-particles');if(!c)return;
22887      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'];
22888      for(var i=0;i<32;i++){(function(idx){
22889        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
22890        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
22891        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
22892        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
22893        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
22894        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
22895        c.appendChild(el);
22896      })(i);}
22897    })();
22898    (function randomizeWatermarks(){
22899      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22900      var placed=[];
22901      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;}
22902      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];}
22903      var half=Math.floor(wms.length/2);
22904      wms.forEach(function(img,i){
22905        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
22906        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
22907        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
22908        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
22909        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
22910        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
22911      });
22912    })();
22913  </script>
22914  <script nonce="{{ csp_nonce }}">
22915  (function(){
22916    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'}];
22917    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);});}
22918    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22919    function init(){
22920      var btn=document.getElementById('settings-btn');if(!btn)return;
22921      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22922      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>';
22923      document.body.appendChild(m);
22924      var g=document.getElementById('scheme-grid');
22925      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);});
22926      var cl=document.getElementById('settings-close');
22927      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);
22928      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');});
22929      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22930      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22931    }
22932    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22933  }());
22934  </script>
22935  <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]';
22936  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;}
22937  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>
22938</body>
22939</html>
22940"##,
22941    ext = "html"
22942)]
22943struct ScanWaitTemplate {
22944    version: &'static str,
22945    wait_id_json: String,
22946    project_path: String,
22947    csp_nonce: String,
22948}
22949
22950#[derive(Template)]
22951#[template(
22952    source = r##"
22953<!doctype html>
22954<html lang="en">
22955<head>
22956  <meta charset="utf-8">
22957  <meta name="viewport" content="width=device-width, initial-scale=1">
22958  <title>OxideSLOC | Error</title>
22959  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22960  <style nonce="{{ csp_nonce }}">
22961    :root {
22962      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22963      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22964      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22965      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22966    }
22967    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22968    *{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;}
22969    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22970    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22971    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
22972    .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);}
22973    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22974    .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));}
22975    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22976    .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;}
22977    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22978    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22979    @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; } }
22980    .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;}
22981    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22982    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22983    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22984    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22985    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22986    .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;}
22987    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22988    .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);}
22989    .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;}
22990    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22991    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22992    .settings-modal-body{padding:14px 16px 16px;}
22993    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22994    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22995    .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;}
22996    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22997    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22998    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22999    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23000    .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;}
23001    .tz-select:focus{border-color:var(--oxide);}
23002    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23003    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
23004    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23005    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23006    .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;}
23007    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23008    .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);}
23009    .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;}
23010    .btn-secondary:hover{background:var(--line);}
23011    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
23012    .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;}
23013    .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;}
23014    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
23015    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
23016    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
23017    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
23018    .bug-report-panel.open{display:flex;}
23019    .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;}
23020    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
23021    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
23022    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
23023    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
23024    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
23025    .br-network-badge.online .br-net-dot{background:#2a6846;}
23026    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
23027    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
23028    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
23029    .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;}
23030    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
23031    .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;}
23032    .btn-sm:hover{background:var(--line);}
23033    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
23034    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
23035    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
23036    .bug-report-hint a:hover{text-decoration:underline;}
23037    .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;}
23038    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23039    .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;}
23040    .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;}
23041    .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;}
23042    @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));}}
23043    .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;}
23044  </style>
23045</head>
23046<body>
23047  <div class="background-watermarks" aria-hidden="true">
23048    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23049    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23050    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23051    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23052    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23053    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23054  </div>
23055  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23056  <div class="top-nav">
23057    <div class="top-nav-inner">
23058      <a class="brand" href="/">
23059        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23060        <div class="brand-copy">
23061          <div class="brand-title">OxideSLOC</div>
23062          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23063        </div>
23064      </a>
23065      <div class="nav-right">
23066        <a class="nav-pill" href="/">Home</a>
23067        <div class="nav-dropdown">
23068          <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>
23069          <div class="nav-dropdown-menu">
23070            <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>
23071          </div>
23072        </div>
23073        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23074        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23075        <div class="nav-dropdown">
23076          <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>
23077          <div class="nav-dropdown-menu">
23078            <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>
23079          </div>
23080        </div>
23081        <div class="server-status-wrap" id="server-status-wrap">
23082          <div class="nav-pill server-online-pill" id="server-status-pill">
23083            <span class="status-dot" id="status-dot"></span>
23084            <span id="server-status-label">Server</span>
23085            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23086          </div>
23087          <div class="server-status-tip">
23088            OxideSLOC is running — accessible on your network.
23089            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23090          </div>
23091        </div>
23092        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23093          <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>
23094        </button>
23095        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23096          <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>
23097          <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>
23098        </button>
23099      </div>
23100    </div>
23101  </div>
23102
23103  <div class="page">
23104    <div class="panel">
23105      <h1>Error</h1>
23106      <div class="error-box" id="error-msg-text">{{ message }}</div>
23107      <div id="br-meta" hidden
23108        data-version="{{ version }}"
23109        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
23110        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
23111      <div class="actions">
23112        <a class="btn-primary" href="/scan">Back to setup</a>
23113        {% if let Some(report_url) = last_report_url %}
23114        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
23115        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
23116        {% else %}
23117        <a class="btn-secondary" href="/view-reports">View Reports</a>
23118        {% endif %}
23119      </div>
23120      <div class="bug-report-section" id="bug-report-section">
23121        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
23122          <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>
23123          Generate Bug Report
23124          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23125        </button>
23126        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
23127          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
23128          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
23129          <div class="bug-report-btns">
23130            <button type="button" class="btn-sm" id="bug-report-copy">
23131              <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>
23132              Copy to clipboard
23133            </button>
23134            <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;">
23135              <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>
23136              Open GitHub Issue
23137            </a>
23138            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
23139              <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>
23140              Save as file
23141            </button>
23142          </div>
23143          <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>
23144          <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>
23145        </div>
23146      </div>
23147    </div>
23148  </div>
23149  <footer class="site-footer">
23150    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
23151    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23152    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23153    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23154    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23155  </footer>
23156  <script nonce="{{ csp_nonce }}">(function(){
23157    var meta=document.getElementById('br-meta');
23158    var pre=document.getElementById('bug-report-pre');
23159    var copyBtn=document.getElementById('bug-report-copy');
23160    var trigger=document.getElementById('bug-report-trigger');
23161    var panel=document.getElementById('bug-report-panel');
23162    var networkBadge=document.getElementById('br-network-badge');
23163    var networkLabel=document.getElementById('br-network-label');
23164    var ghLink=document.getElementById('bug-report-github-link');
23165    var saveBtn=document.getElementById('bug-report-save');
23166    var hintOnline=document.getElementById('br-hint-online');
23167    var hintOffline=document.getElementById('br-hint-offline');
23168    if(!meta||!pre)return;
23169    var ver=meta.getAttribute('data-version')||'';
23170    var runId=meta.getAttribute('data-run-id')||'';
23171    var code=meta.getAttribute('data-error-code')||'';
23172    var msgEl=document.getElementById('error-msg-text');
23173    var msg=msgEl?msgEl.textContent.trim():'';
23174    function getBrowser(){
23175      var ua=navigator.userAgent;
23176      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
23177      if(!m)return 'Unknown browser';
23178      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
23179      return n+' '+m[2];
23180    }
23181    var lines=['oxide-sloc Bug Report','==============================',''];
23182    lines.push('App version:  v'+ver);
23183    if(code)lines.push('HTTP status:  '+code);
23184    if(runId)lines.push('Run ID:       '+runId);
23185    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
23186    lines.push('Timestamp:    '+new Date().toISOString());
23187    lines.push('Browser:      '+getBrowser());
23188    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
23189    lines.push('');
23190    lines.push('Error message:');
23191    lines.push(msg);
23192    lines.push('');
23193    lines.push('Steps to reproduce:');
23194    lines.push('  1. ');
23195    lines.push('');
23196    lines.push('Expected behavior:');
23197    lines.push('  ');
23198    pre.textContent=lines.join('\n');
23199    function applyNetwork(online){
23200      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
23201      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
23202      if(ghLink){
23203        if(online){
23204          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
23205          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
23206        }
23207        ghLink.style.display=online?'inline-flex':'none';
23208      }
23209      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
23210      if(hintOnline)hintOnline.style.display=online?'block':'none';
23211      if(hintOffline)hintOffline.style.display=online?'none':'block';
23212    }
23213    applyNetwork(navigator.onLine);
23214    var probed=false;
23215    function probeNetwork(){
23216      if(probed)return;probed=true;
23217      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
23218      var probeIdx=0;
23219      function tryNext(){
23220        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
23221        var u=probeUrls[probeIdx++];
23222        var c2=new AbortController();
23223        var t2=setTimeout(function(){c2.abort();},4000);
23224        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
23225          .then(function(){clearTimeout(t2);applyNetwork(true);})
23226          .catch(function(){clearTimeout(t2);tryNext();});
23227      }
23228      tryNext();
23229    }
23230    if(trigger&&panel){
23231      trigger.addEventListener('click',function(){
23232        var open=panel.classList.toggle('open');
23233        trigger.classList.toggle('open',open);
23234        trigger.setAttribute('aria-expanded',open?'true':'false');
23235        if(open)probeNetwork();
23236      });
23237    }
23238    if(copyBtn){
23239      copyBtn.addEventListener('click',function(){
23240        var txt=pre.textContent;
23241        if(navigator.clipboard&&navigator.clipboard.writeText){
23242          navigator.clipboard.writeText(txt).then(function(){
23243            copyBtn.textContent='✓ Copied!';
23244            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);
23245          });
23246        }else{
23247          var ta=document.createElement('textarea');
23248          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
23249          document.body.appendChild(ta);ta.select();
23250          try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
23251          document.body.removeChild(ta);
23252        }
23253      });
23254    }
23255    if(saveBtn){
23256      saveBtn.addEventListener('click',function(){
23257        var txt=pre.textContent;
23258        var blob=new Blob([txt],{type:'text/plain'});
23259        var url=URL.createObjectURL(blob);
23260        var a=document.createElement('a');
23261        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
23262        document.body.appendChild(a);a.click();
23263        document.body.removeChild(a);URL.revokeObjectURL(url);
23264      });
23265    }
23266  })();</script>
23267  <script nonce="{{ csp_nonce }}">
23268    (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");});})();
23269    (function spawnCodeParticles() {
23270      var container = document.getElementById('code-particles');
23271      if (!container) return;
23272      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'];
23273      for (var i = 0; i < 38; i++) {
23274        (function(idx) {
23275          var el = document.createElement('span');
23276          el.className = 'code-particle';
23277          el.textContent = snippets[idx % snippets.length];
23278          var left = Math.random() * 94 + 2;
23279          var top = Math.random() * 88 + 6;
23280          var dur = (Math.random() * 10 + 9).toFixed(1);
23281          var delay = (Math.random() * 18).toFixed(1);
23282          var rot = (Math.random() * 26 - 13).toFixed(1);
23283          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23284          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';
23285          container.appendChild(el);
23286        })(i);
23287      }
23288    })();
23289    (function randomizeWatermarks() {
23290      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23291      var placed = [];
23292      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; }
23293      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]; }
23294      var half = Math.floor(wms.length/2);
23295      wms.forEach(function(img, i) {
23296        var pos = pick(i < half);
23297        var w = Math.floor(Math.random()*60+80);
23298        var rot = (Math.random()*40-20).toFixed(1);
23299        var op = (Math.random()*0.08+0.05).toFixed(2);
23300        var animDur = (Math.random()*6+5).toFixed(1);
23301        var animDelay = (Math.random()*10).toFixed(1);
23302        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';
23303      });
23304    })();
23305  </script>
23306  <script nonce="{{ csp_nonce }}">
23307  (function(){
23308    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'}];
23309    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);});}
23310    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23311    function init(){
23312      var btn=document.getElementById('settings-btn');if(!btn)return;
23313      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23314      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>';
23315      document.body.appendChild(m);
23316      var g=document.getElementById('scheme-grid');
23317      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);});
23318      var cl=document.getElementById('settings-close');
23319      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);
23320      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');});
23321      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23322      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23323    }
23324    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23325  }());
23326  </script>
23327  <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]';
23328  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;}
23329  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>
23330</body>
23331</html>
23332"##,
23333    ext = "html"
23334)]
23335struct ErrorTemplate {
23336    message: String,
23337    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
23338    last_report_url: Option<String>,
23339    /// Label for the secondary action button; defaults to "View last report" when None.
23340    last_report_label: Option<String>,
23341    /// Run ID to surface in the bug report; `None` when not applicable.
23342    run_id: Option<String>,
23343    /// HTTP status code to surface in the bug report; `None` when unknown.
23344    error_code: Option<u16>,
23345    csp_nonce: String,
23346    version: &'static str,
23347}
23348
23349// ── LocateFileTemplate ────────────────────────────────────────────────────────
23350
23351#[derive(Template)]
23352#[template(
23353    source = r##"
23354<!doctype html>
23355<html lang="en">
23356<head>
23357  <meta charset="utf-8">
23358  <meta name="viewport" content="width=device-width, initial-scale=1">
23359  <title>OxideSLOC | Locate Report</title>
23360  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23361  <style nonce="{{ csp_nonce }}">
23362    :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);}
23363    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
23364    *{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;}
23365    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23366    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23367    .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);}
23368    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23369    .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));}
23370    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23371    .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;}
23372    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23373    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23374    @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;}}
23375    .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;}
23376    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23377    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23378    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23379    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23380    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
23381    .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;}
23382    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23383    .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);}
23384    .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;}
23385    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23386    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23387    .settings-modal-body{padding:14px 16px 16px;}
23388    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23389    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23390    .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;}
23391    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23392    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23393    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23394    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23395    .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;}
23396    .tz-select:focus{border-color:var(--oxide);}
23397    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23398    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23399    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23400    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
23401    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
23402    .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;}
23403    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
23404    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23405    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23406    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23407    .locate-row{display:flex;gap:8px;align-items:stretch;}
23408    .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;}
23409    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23410    body.dark-theme .locate-input{background:var(--surface-2);}
23411    .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;}
23412    .warning-banner.show{display:flex;}
23413    .warning-banner svg{flex:0 0 auto;}
23414    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
23415    .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;}
23416    .error-inline.show{display:flex;}
23417    .error-inline svg{flex:0 0 auto;margin-top:2px;}
23418    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
23419    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
23420    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
23421    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
23422    .err-kv-p{margin:0 0 4px;}
23423    .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;}
23424    .success-inline.show{display:flex;}
23425    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23426    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
23427    .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;}
23428    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
23429    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
23430    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
23431    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
23432    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
23433    .fh-row:last-child{border-bottom:none;}
23434    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
23435    .fh-dir{font-weight:800;color:var(--text);}
23436    .fh-hl{color:var(--oxide);font-weight:700;}
23437    .fh-muted{color:var(--muted);}
23438    .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;}
23439    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
23440    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
23441    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
23442    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
23443    .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;}
23444    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
23445    .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;}
23446    .btn-secondary:hover{background:var(--line);}
23447    .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;}
23448    .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;}
23449    .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;}
23450    @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));}}
23451    .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;}
23452    .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;}
23453    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23454  </style>
23455</head>
23456<body>
23457  <div class="background-watermarks" aria-hidden="true">
23458    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23459    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23460    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23461    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23462    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23463    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23464  </div>
23465  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23466  <div class="top-nav">
23467    <div class="top-nav-inner">
23468      <a class="brand" href="/">
23469        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23470        <div class="brand-copy">
23471          <div class="brand-title">OxideSLOC</div>
23472          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23473        </div>
23474      </a>
23475      <div class="nav-right">
23476        <a class="nav-pill" href="/">Home</a>
23477        <div class="nav-dropdown">
23478          <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>
23479          <div class="nav-dropdown-menu">
23480            <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>
23481          </div>
23482        </div>
23483        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23484        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23485        <div class="nav-dropdown">
23486          <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>
23487          <div class="nav-dropdown-menu">
23488            <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>
23489          </div>
23490        </div>
23491        <div class="server-status-wrap" id="server-status-wrap">
23492          <div class="nav-pill server-online-pill" id="server-status-pill">
23493            <span class="status-dot" id="status-dot"></span>
23494            <span id="server-status-label">Server</span>
23495            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23496          </div>
23497          <div class="server-status-tip">
23498            OxideSLOC is running &mdash; accessible on your network.
23499            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23500          </div>
23501        </div>
23502        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23503          <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>
23504        </button>
23505        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23506          <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>
23507          <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>
23508        </button>
23509      </div>
23510    </div>
23511  </div>
23512
23513  <div class="page">
23514    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
23515    <div class="panel">
23516      <h1>Report File Not Found</h1>
23517      <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>
23518      <div class="field-label">Missing file</div>
23519      <div class="filename-chip">
23520        <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>
23521        {{ expected_filename }}
23522      </div>
23523      <div class="locate-section">
23524        <h2>Locate Scan Output Folder</h2>
23525        <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>
23526        <p>OxideSLOC will find the correct files inside automatically.</p>
23527        <div class="locate-row">
23528          <input type="text" id="locate-file-input"
23529                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
23530                 class="locate-input" autocomplete="off" spellcheck="false">
23531          {% if !server_mode %}
23532          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
23533          {% endif %}
23534        </div>
23535        <div class="warning-banner" id="filename-warning">
23536          <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>
23537          <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>
23538        </div>
23539        <div class="error-inline" id="locate-error">
23540          <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>
23541          <span id="locate-error-text"></span>
23542        </div>
23543        <div class="success-inline" id="locate-success">
23544          <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>
23545          <span>Scan restored &mdash; loading report&hellip;</span>
23546        </div>
23547        <div class="btn-row">
23548          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
23549          <a class="btn-secondary" href="/view-reports">View Reports</a>
23550        </div>
23551        <div class="folder-hint-shell">
23552          <div class="folder-hint-hdr">
23553            <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>
23554            Expected Folder Structure &mdash; Select the Top-Level Folder
23555          </div>
23556          <div class="folder-hint-body">
23557            <div class="fh-row">
23558              <span class="fh-tog">&#9658;</span>
23559              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
23560              <span class="fh-badge">&larr; select this</span>
23561            </div>
23562            <div class="fh-row fh-i1">
23563              <span class="fh-tog">&#9658;</span>
23564              <span class="fh-dir">html/</span>
23565            </div>
23566            <div class="fh-row fh-i2">
23567              <span class="fh-bul">&#8226;</span>
23568              <span class="fh-hl">{{ expected_filename }}</span>
23569            </div>
23570            <div class="fh-row fh-i1">
23571              <span class="fh-tog">&#9658;</span>
23572              <span class="fh-dir">json/</span>
23573            </div>
23574            <div class="fh-row fh-i2">
23575              <span class="fh-bul">&#8226;</span>
23576              <span class="fh-muted">result_*.json</span>
23577            </div>
23578            <div class="fh-row fh-i1">
23579              <span class="fh-tog">&#9658;</span>
23580              <span class="fh-dir">pdf/</span>
23581            </div>
23582            <div class="fh-row fh-i2">
23583              <span class="fh-bul">&#8226;</span>
23584              <span class="fh-muted">report_*.pdf</span>
23585            </div>
23586            <div class="fh-row fh-i1">
23587              <span class="fh-tog">&#9658;</span>
23588              <span class="fh-dir">excel/</span>
23589            </div>
23590            <div class="fh-row fh-i2">
23591              <span class="fh-bul">&#8226;</span>
23592              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
23593            </div>
23594          </div>
23595        </div>
23596      </div>
23597    </div>
23598  </div>
23599  <footer class="site-footer">
23600    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
23601    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23602    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23603    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23604    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23605  </footer>
23606  <script nonce="{{ csp_nonce }}">(function(){
23607    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
23608    if(s==="dark")b.classList.add("dark-theme");
23609    document.getElementById("theme-toggle").addEventListener("click",function(){
23610      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
23611    });
23612  })();</script>
23613  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
23614    var c=document.getElementById('code-particles');if(!c)return;
23615    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'];
23616    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);}
23617  })();
23618  (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>
23619  <script nonce="{{ csp_nonce }}">(function(){
23620    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'}];
23621    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);});}
23622    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23623    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');});}
23624    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23625  }());</script>
23626  <script nonce="{{ csp_nonce }}">(function(){
23627    var meta=document.getElementById('locate-meta');
23628    var inp=document.getElementById('locate-file-input');
23629    var browseBtn=document.getElementById('browse-locate-btn');
23630    var submitBtn=document.getElementById('locate-submit-btn');
23631    var warning=document.getElementById('filename-warning');
23632    var errBox=document.getElementById('locate-error');
23633    var errText=document.getElementById('locate-error-text');
23634    var okBox=document.getElementById('locate-success');
23635    var expected=meta?meta.getAttribute('data-expected'):'';
23636    var runId=meta?meta.getAttribute('data-run-id'):'';
23637    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
23638    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
23639    function showErr(msg){
23640      if(errText){
23641        errText.innerHTML='';
23642        var lines=msg.split('\n');
23643        var hasPairs=lines.some(function(l){return / : /.test(l);});
23644        if(!hasPairs){errText.textContent=msg;}
23645        else{
23646          var frag=document.createDocumentFragment();var tbl=null;
23647          lines.forEach(function(line){
23648            var m=line.match(/^(.*?) : (.*)$/);
23649            if(m){
23650              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
23651              var tr=document.createElement('tr');
23652              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
23653              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
23654              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
23655            } else {
23656              tbl=null;
23657              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
23658            }
23659          });
23660          errText.appendChild(frag);
23661        }
23662      }
23663      if(errBox)errBox.classList.add('show');
23664      if(okBox)okBox.classList.remove('show');
23665    }
23666    function clearErr(){
23667      if(errBox)errBox.classList.remove('show');
23668      if(okBox)okBox.classList.remove('show');
23669    }
23670    function validate(){
23671      var val=inp?inp.value.trim():'';
23672      clearErr();
23673      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
23674      if(submitBtn)submitBtn.disabled=false;
23675      if(warning){
23676        var name=basename(val);
23677        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
23678        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
23679        else warning.classList.remove('show');
23680      }
23681    }
23682    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
23683    if(browseBtn){
23684      browseBtn.addEventListener('click',function(){
23685        browseBtn.disabled=true;browseBtn.textContent='...';
23686        fetch('/pick-directory')
23687          .then(function(r){return r.ok?r.json():{cancelled:true};})
23688          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
23689          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23690      });
23691    }
23692    if(submitBtn){
23693      submitBtn.addEventListener('click',function(){
23694        var folder=inp?inp.value.trim():'';
23695        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
23696        clearErr();
23697        submitBtn.disabled=true;submitBtn.textContent='Restoring…';
23698        var body=new URLSearchParams();
23699        body.set('file_path',folder);
23700        body.set('redirect_url',redirectUrl);
23701        body.set('expected_run_id',runId);
23702        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23703          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
23704          .then(function(d){
23705            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23706            if(d&&d.ok){
23707              if(okBox)okBox.classList.add('show');
23708              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
23709            } else {
23710              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
23711            }
23712          })
23713          .catch(function(e){
23714            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23715            showErr('Network error: '+String(e));
23716          });
23717      });
23718    }
23719  })();</script>
23720  <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>
23721</body>
23722</html>
23723"##,
23724    ext = "html"
23725)]
23726struct LocateFileTemplate {
23727    run_id: String,
23728    artifact_type: String,
23729    expected_filename: String,
23730    server_mode: bool,
23731    csp_nonce: String,
23732    version: &'static str,
23733}
23734
23735// ── RelocateScanTemplate ──────────────────────────────────────────────────────
23736
23737#[derive(Template)]
23738#[template(
23739    source = r##"
23740<!doctype html>
23741<html lang="en">
23742<head>
23743  <meta charset="utf-8">
23744  <meta name="viewport" content="width=device-width, initial-scale=1">
23745  <title>OxideSLOC | Locate Scan Files</title>
23746  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23747  <style nonce="{{ csp_nonce }}">
23748    :root {
23749      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23750      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23751      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
23752      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23753    }
23754    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
23755    *{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;}
23756    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23757    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23758    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
23759    .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);}
23760    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23761    .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));}
23762    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23763    .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;}
23764    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23765    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23766    @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;}}
23767    .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;}
23768    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23769    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23770    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23771    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23772    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23773    .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;}
23774    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23775    .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);}
23776    .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;}
23777    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23778    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23779    .settings-modal-body{padding:14px 16px 16px;}
23780    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23781    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23782    .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;}
23783    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23784    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23785    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23786    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23787    .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;}
23788    .tz-select:focus{border-color:var(--oxide);}
23789    .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23790    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23791    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23792    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
23793    .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;}
23794    .error-box.hidden{display:none;}
23795    .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;}
23796    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23797    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23798    .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;}
23799    .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;}
23800    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
23801    .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;}
23802    .btn-secondary:hover{background:var(--line);}
23803    .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;}
23804    .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;}
23805    .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;}
23806    @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));}}
23807    .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;}
23808    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23809    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23810    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23811    .relocate-row{display:flex;gap:8px;align-items:stretch;}
23812    .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;}
23813    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23814    body.dark-theme .relocate-input{background:var(--surface-2);}
23815  </style>
23816</head>
23817<body>
23818  <div class="background-watermarks" aria-hidden="true">
23819    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23820    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23821    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23822    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23823    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23824    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23825  </div>
23826  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23827  <div class="top-nav">
23828    <div class="top-nav-inner">
23829      <a class="brand" href="/">
23830        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23831        <div class="brand-copy">
23832          <div class="brand-title">OxideSLOC</div>
23833          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23834        </div>
23835      </a>
23836      <div class="nav-right">
23837        <a class="nav-pill" href="/">Home</a>
23838        <div class="nav-dropdown">
23839          <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>
23840          <div class="nav-dropdown-menu">
23841            <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>
23842          </div>
23843        </div>
23844        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
23845        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23846        <div class="nav-dropdown">
23847          <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>
23848          <div class="nav-dropdown-menu">
23849            <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>
23850          </div>
23851        </div>
23852        <div class="server-status-wrap" id="server-status-wrap">
23853          <div class="nav-pill server-online-pill" id="server-status-pill">
23854            <span class="status-dot" id="status-dot"></span>
23855            <span id="server-status-label">Server</span>
23856            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23857          </div>
23858          <div class="server-status-tip">
23859            OxideSLOC is running — accessible on your network.
23860            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23861          </div>
23862        </div>
23863        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23864          <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>
23865        </button>
23866        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23867          <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>
23868          <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>
23869        </button>
23870      </div>
23871    </div>
23872  </div>
23873
23874  <div class="page">
23875    <div class="panel">
23876      <h1>Scan Files Moved</h1>
23877      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
23878      <div class="error-box" id="relocate-error-box">{{ message }}</div>
23879      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
23880      <div class="relocate-section">
23881        <h2>Locate Scan Output</h2>
23882        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
23883        <div class="relocate-row">
23884          <input type="text" id="relocate-folder" name="folder_path"
23885                 value="{{ folder_hint }}"
23886                 placeholder="Path to folder containing scan output..."
23887                 class="relocate-input" autocomplete="off" spellcheck="false">
23888          {% if !server_mode %}
23889          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
23890          {% endif %}
23891        </div>
23892        <div style="margin-top:12px;">
23893          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
23894        </div>
23895      </div>
23896      <div class="actions">
23897        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
23898        <a class="btn-secondary" href="/view-reports">View Reports</a>
23899      </div>
23900    </div>
23901  </div>
23902  <footer class="site-footer">
23903    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
23904    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23905    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23906    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23907    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23908  </footer>
23909  <script nonce="{{ csp_nonce }}">
23910    (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");});})();
23911    (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);}})();
23912    (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;});})();
23913  </script>
23914  <script nonce="{{ csp_nonce }}">
23915  (function(){
23916    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'}];
23917    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);});}
23918    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23919    function init(){
23920      var btn=document.getElementById('settings-btn');if(!btn)return;
23921      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23922      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>';
23923      document.body.appendChild(m);
23924      var g=document.getElementById('scheme-grid');
23925      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);});
23926      var cl=document.getElementById('settings-close');
23927      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);
23928      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');});
23929      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23930      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23931    }
23932    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23933  }());
23934  (function(){
23935    var browseBtn=document.getElementById('browse-relocate-btn');
23936    if(browseBtn){
23937      browseBtn.addEventListener('click',function(){
23938        browseBtn.disabled=true;browseBtn.textContent='...';
23939        var inp=document.getElementById('relocate-folder');
23940        var hint=inp?inp.value:'';
23941        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
23942          .then(function(r){return r.ok?r.json():{cancelled:true};})
23943          .then(function(d){
23944            browseBtn.disabled=false;browseBtn.textContent='Browse…';
23945            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
23946          })
23947          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23948      });
23949    }
23950    var restoreBtn=document.getElementById('restore-btn');
23951    var errBox=document.getElementById('relocate-error-box');
23952    var okBox=document.getElementById('relocate-success-box');
23953    if(restoreBtn){
23954      restoreBtn.addEventListener('click',function(){
23955        var inp=document.getElementById('relocate-folder');
23956        var folder=inp?inp.value.trim():'';
23957        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
23958        restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
23959        var body=new URLSearchParams();
23960        body.set('run_id','{{ run_id }}');
23961        body.set('redirect_url','{{ redirect_url }}');
23962        body.set('folder_path',folder);
23963        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23964          .then(function(r){return r.json();})
23965          .then(function(d){
23966            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23967            if(d&&d.ok){
23968              if(errBox)errBox.classList.add('hidden');
23969              if(okBox){okBox.style.display='block';}
23970              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
23971            } else {
23972              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
23973            }
23974          })
23975          .catch(function(e){
23976            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23977            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
23978          });
23979      });
23980    }
23981  }());
23982  </script>
23983  <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]';
23984  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;}
23985  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>
23986</body>
23987</html>
23988"##,
23989    ext = "html"
23990)]
23991struct RelocateScanTemplate {
23992    message: String,
23993    run_id: String,
23994    folder_hint: String,
23995    redirect_url: String,
23996    server_mode: bool,
23997    csp_nonce: String,
23998    version: &'static str,
23999}
24000
24001// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
24002
24003#[derive(Template)]
24004#[template(
24005    source = r##"
24006<!doctype html>
24007<html lang="en">
24008<head>
24009  <meta charset="utf-8">
24010  <meta name="viewport" content="width=device-width, initial-scale=1">
24011  <title>OxideSLOC | View Reports</title>
24012  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24013  <style nonce="{{ csp_nonce }}">
24014    :root {
24015      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24016      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24017      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24018      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24019      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
24020    }
24021    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; }
24022    *{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;}
24023    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24024    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24025    .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);}
24026    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24027    .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));}
24028    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24029    .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;}
24030    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24031    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24032    @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; } }
24033    .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;}
24034    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24035    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24036    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24037    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24038    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24039    .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;}
24040    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24041    .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);}
24042    .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;}
24043    .settings-close:hover{color:var(--text);background:var(--surface-2);}
24044    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24045    .settings-modal-body{padding:14px 16px 16px;}
24046    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24047    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24048    .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;}
24049    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24050    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24051    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24052    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24053    .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;}
24054    .tz-select:focus{border-color:var(--oxide);}
24055    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24056    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24057    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24058    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24059    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24060    .panel-meta{font-size:13px;color:var(--muted);}
24061    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24062    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24063    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24064    .per-page-label{font-size:13px;color:var(--muted);}
24065    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;}
24066    .filter-input{min-width:180px;cursor:text;}
24067    .table-wrap{width:100%;overflow-x:auto;}
24068    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
24069    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;}
24070    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24071    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24072    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24073    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24074    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24075    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24076    tr:last-child td{border-bottom:none;}
24077    tr:hover td{background:var(--surface-2);}
24078    .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);}
24079    .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);}
24080    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24081    .metric-num{font-weight:700;color:var(--text);}
24082    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24083    .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;}
24084    .btn:hover{background:var(--line);}
24085    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24086    .btn.primary:hover{opacity:.9;}
24087    .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;}
24088    .btn-back:hover{background:var(--line);}
24089    .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;}
24090    .export-btn:hover{background:var(--line);}
24091    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
24092    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
24093    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
24094    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24095    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24096    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24097    .pagination-info{font-size:13px;color:var(--muted);}
24098    .pagination-btns{display:flex;gap:6px;}
24099    .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;}
24100    .pg-btn:hover:not(:disabled){background:var(--line);}
24101    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24102    .pg-btn:disabled{opacity:.35;cursor:default;}
24103    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24104    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24105    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
24106    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24107    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24108    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24109    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
24110    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24111    .stat-chip:hover .stat-chip-tip{opacity:1;}
24112    .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;}
24113    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24114    .site-footer a{color:var(--muted);}
24115    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24116    .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%;}
24117    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
24118    .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;}
24119    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
24120    .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;}
24121    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
24122    .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;}
24123    .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;}
24124    .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;}
24125    @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));}}
24126    .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;}
24127    .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;}
24128    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24129    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24130    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24131    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24132    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24133    .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;}
24134    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24135    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24136    .watched-chip-rm:hover{color:var(--oxide);}
24137    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24138    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24139    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24140    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24141    .rpt-btn{min-width:58px;justify-content:center;}
24142    .flex-row{display:flex;align-items:center;gap:8px;}
24143    .report-cell{overflow:visible;white-space:normal;}
24144    #history-table col:nth-child(1){width:185px;}
24145    #history-table col:nth-child(2){width:220px;}
24146    #history-table col:nth-child(3){width:100px;}
24147    #history-table col:nth-child(4){width:72px;}
24148    #history-table col:nth-child(5){width:82px;}
24149    #history-table col:nth-child(6){width:82px;}
24150    #history-table col:nth-child(7){width:65px;}
24151    #history-table col:nth-child(8){width:90px;}
24152    #history-table col:nth-child(9){width:85px;}
24153    #history-table col:nth-child(10){width:115px;}
24154    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
24155    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
24156    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
24157    .submod-details summary::-webkit-details-marker{display:none;}
24158.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
24159    .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;}
24160    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
24161    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
24162  </style>
24163</head>
24164<body>
24165  <div class="background-watermarks" aria-hidden="true">
24166    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24167    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24168    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24169    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24170    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24171    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24172  </div>
24173  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24174  <div class="top-nav">
24175    <div class="top-nav-inner">
24176      <a class="brand" href="/">
24177        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24178        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
24179      </a>
24180      <div class="nav-right">
24181        <a class="nav-pill" href="/">Home</a>
24182        <div class="nav-dropdown">
24183          <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>
24184          <div class="nav-dropdown-menu">
24185            <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>
24186          </div>
24187        </div>
24188        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24189        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24190        <div class="nav-dropdown">
24191          <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>
24192          <div class="nav-dropdown-menu">
24193            <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>
24194          </div>
24195        </div>
24196        <div class="server-status-wrap" id="server-status-wrap">
24197          <div class="nav-pill server-online-pill" id="server-status-pill">
24198            <span class="status-dot" id="status-dot"></span>
24199            <span id="server-status-label">Server</span>
24200            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24201          </div>
24202          <div class="server-status-tip">
24203            OxideSLOC is running — accessible on your network.
24204            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24205          </div>
24206        </div>
24207        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24208          <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>
24209        </button>
24210        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24211          <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>
24212          <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>
24213        </button>
24214      </div>
24215    </div>
24216  </div>
24217
24218  <div class="page">
24219    {% if let Some(err) = browse_error %}
24220    <div class="toast-error">
24221      <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>
24222      {{ err }}
24223    </div>
24224    {% endif %}
24225    {% if linked_count > 0 %}
24226    <div class="toast-success">
24227      <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>
24228      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
24229    </div>
24230    {% endif %}
24231    <div class="watched-bar">
24232      <div class="watched-bar-left">
24233        <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>
24234        <span class="watched-label">Watched Folders</span>
24235        <div class="watched-chips">
24236          {% if server_mode %}
24237          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24238          {% else %}
24239          {% for dir in watched_dirs %}
24240          <span class="watched-chip">
24241            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24242            <form method="POST" action="/watched-dirs/remove" style="display:contents">
24243              <input type="hidden" name="folder_path" value="{{ dir }}">
24244              <input type="hidden" name="redirect_to" value="/view-reports">
24245              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
24246            </form>
24247          </span>
24248          {% endfor %}
24249          {% if watched_dirs.is_empty() %}
24250          <span class="watched-none">No folders watched — click Choose to add one</span>
24251          {% endif %}
24252          {% endif %}
24253        </div>
24254      </div>
24255      {% if !server_mode %}
24256      <div class="watched-bar-right">
24257        <button type="button" class="btn" id="add-watched-btn">
24258          <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>
24259          Choose
24260        </button>
24261        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24262          <input type="hidden" name="redirect_to" value="/view-reports">
24263          <button type="submit" class="btn">&#8635; Refresh</button>
24264        </form>
24265      </div>
24266      {% endif %}
24267    </div>
24268    {% if total_scans > 0 %}
24269    <div class="summary-strip">
24270      <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>
24271      <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>
24272      <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>
24273      <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>
24274    </div>
24275    {% endif %}
24276
24277    <section class="panel">
24278      <div class="panel-header">
24279        <div>
24280          <h1>View Reports</h1>
24281          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
24282          {% 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 %}
24283        </div>
24284        <div class="flex-row">
24285          <button type="button" class="export-btn" id="export-csv-btn">
24286            <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>
24287            Export CSV
24288          </button>
24289          <button type="button" class="export-btn" id="export-xls-btn">
24290            <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>
24291            Export Excel
24292          </button>
24293        </div>
24294      </div>
24295
24296      {% if entries.is_empty() %}
24297      <div class="empty-state">
24298        <strong>No reports with viewable HTML yet</strong>
24299        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.
24300      </div>
24301      {% else %}
24302      <div class="filter-row">
24303        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
24304        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24305        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
24306      </div>
24307      <div class="table-wrap">
24308        <table id="history-table">
24309          <colgroup>
24310            <col><col><col><col><col><col><col><col><col><col>
24311          </colgroup>
24312          <thead>
24313            <tr id="history-thead">
24314              <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>
24315              <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>
24316              <th>Run ID<div class="col-resize-handle"></div></th>
24317              <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>
24318              <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>
24319              <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>
24320              <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>
24321              <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>
24322              <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>
24323              <th>Report<div class="col-resize-handle"></div></th>
24324            </tr>
24325          </thead>
24326          <tbody id="history-tbody">
24327            {% for entry in entries %}
24328            <tr class="history-row" data-run="{{ entry.run_id }}"
24329                data-timestamp="{{ entry.timestamp }}"
24330                data-project="{{ entry.project_label }}"
24331                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
24332                data-skipped="{{ entry.files_skipped }}"
24333                data-comments="{{ entry.comment_lines }}"
24334                data-blank="{{ entry.blank_lines }}"
24335                data-branch="{{ entry.git_branch }}"
24336                data-commit="{{ entry.git_commit }}"
24337                data-html-url="/runs/html/{{ entry.run_id }}">
24338              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
24339              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
24340              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
24341              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
24342              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
24343              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
24344              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
24345              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
24346              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip" title="{{ entry.git_commit }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
24347              <td class="report-cell">
24348                <div class="actions-cell">
24349                  {% 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 %}
24350                  {% 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 %}
24351                </div>
24352                {% if !entry.submodule_links.is_empty() %}
24353                <details class="submod-details">
24354                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
24355                  <div class="submod-link-list">
24356                    {% for sub in entry.submodule_links %}
24357                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
24358                    {% endfor %}
24359                  </div>
24360                </details>
24361                {% endif %}
24362              </td>
24363            </tr>
24364            {% endfor %}
24365          </tbody>
24366        </table>
24367      </div>
24368      <div class="pagination">
24369        <span class="pagination-info" id="pagination-info"></span>
24370        <div class="pagination-btns" id="pagination-btns"></div>
24371        <div class="flex-row">
24372          <span class="per-page-label">Show</span>
24373          <select class="per-page" id="per-page-sel">
24374            <option value="10">10 per page</option>
24375            <option value="25" selected>25 per page</option>
24376            <option value="50">50 per page</option>
24377            <option value="100">100 per page</option>
24378          </select>
24379          <span class="per-page-label" id="page-range-label"></span>
24380        </div>
24381      </div>
24382      {% endif %}
24383    </section>
24384  </div>
24385
24386  <footer class="site-footer">
24387    local code analysis - metrics, history and reports
24388    &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>
24389    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24390    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24391    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24392    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
24393  </footer>
24394
24395  <script nonce="{{ csp_nonce }}">
24396    (function () {
24397      // ── Theme ──────────────────────────────────────────────────────────────
24398      var storageKey = 'oxide-sloc-theme';
24399      var body = document.body;
24400      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
24401      var toggle = document.getElementById('theme-toggle');
24402      if (toggle) toggle.addEventListener('click', function () {
24403        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
24404        body.classList.toggle('dark-theme', next === 'dark');
24405        try { localStorage.setItem(storageKey, next); } catch(e) {}
24406      });
24407
24408      // ── State ─────────────────────────────────────────────────────────────
24409      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
24410      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
24411      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
24412
24413      // Aggregate stats from first (most recent) row
24414      if (allRows.length) {
24415        var first = allRows[0];
24416        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();}
24417        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>':'');}
24418        setChipVal('agg-code', first.dataset.code);
24419        setChipVal('agg-files', first.dataset.files);
24420        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
24421        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
24422      }
24423
24424      // ── Branch filter population ──────────────────────────────────────────
24425      (function() {
24426        var branches = {};
24427        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
24428        var sel = document.getElementById('branch-filter');
24429        if (sel) Object.keys(branches).sort().forEach(function(b) {
24430          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
24431        });
24432      })();
24433
24434      // ── Filter ────────────────────────────────────────────────────────────
24435      function getFilteredRows() {
24436        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
24437        var branch = ((document.getElementById('branch-filter') || {}).value || '');
24438        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
24439          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
24440          if (branch && (r.dataset.branch || '') !== branch) return false;
24441          return true;
24442        });
24443      }
24444
24445      // ── Pagination ────────────────────────────────────────────────────────
24446      function renderPage() {
24447        var filtered = getFilteredRows();
24448        var total = filtered.length;
24449        var totalPages = Math.max(1, Math.ceil(total / perPage));
24450        currentPage = Math.min(currentPage, totalPages);
24451        var start = (currentPage - 1) * perPage;
24452        var end = Math.min(start + perPage, total);
24453        var shown = {};
24454        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
24455        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
24456          r.style.display = shown[r.dataset.run] ? '' : 'none';
24457        });
24458        var rl = document.getElementById('page-range-label');
24459        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
24460        var info = document.getElementById('pagination-info');
24461        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
24462        var btns = document.getElementById('pagination-btns');
24463        if (!btns) return;
24464        btns.innerHTML = '';
24465        function makeBtn(lbl, pg, active, disabled) {
24466          var b = document.createElement('button');
24467          b.className = 'pg-btn' + (active ? ' active' : '');
24468          b.textContent = lbl; b.disabled = disabled;
24469          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
24470          return b;
24471        }
24472        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
24473        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
24474        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
24475        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
24476      }
24477
24478      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
24479      window.applyFilters = function() { currentPage = 1; renderPage(); };
24480
24481      // ── Sorting ───────────────────────────────────────────────────────────
24482      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
24483      function doSort(col, type, order) {
24484        var tbody = document.getElementById('history-tbody');
24485        if (!tbody) return;
24486        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24487        rows.sort(function(a, b) {
24488          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
24489          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
24490          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
24491          return va < vb ? 1 : va > vb ? -1 : 0;
24492        });
24493        rows.forEach(function(r) { tbody.appendChild(r); });
24494        currentPage = 1; renderPage();
24495      }
24496      sortHeaders.forEach(function(th) {
24497        th.addEventListener('click', function(e) {
24498          if (e.target.classList.contains('col-resize-handle')) return;
24499          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
24500          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
24501          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24502          th.classList.add('sort-' + sortOrder);
24503          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
24504          doSort(col, type, sortOrder);
24505        });
24506      });
24507
24508      // ── Column resize ─────────────────────────────────────────────────────
24509      (function() {
24510        var table = document.getElementById('history-table');
24511        if (!table) return;
24512        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
24513        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
24514        ths.forEach(function(th, i) {
24515          var handle = th.querySelector('.col-resize-handle');
24516          if (!handle || !cols[i]) return;
24517          var startX, startW;
24518          handle.addEventListener('mousedown', function(e) {
24519            e.stopPropagation(); e.preventDefault();
24520            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
24521            handle.classList.add('dragging');
24522            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
24523            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
24524            document.addEventListener('mousemove', onMove);
24525            document.addEventListener('mouseup', onUp);
24526          });
24527        });
24528      })();
24529
24530      // ── Reset view ────────────────────────────────────────────────────────
24531      window.resetView = function() {
24532        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
24533        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
24534        sortCol = null; sortOrder = 'asc';
24535        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24536        var tbody = document.getElementById('history-tbody');
24537        if (tbody) {
24538          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24539          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
24540          rows.forEach(function(r) { tbody.appendChild(r); });
24541        }
24542        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
24543        var table = document.getElementById('history-table');
24544        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
24545        currentPage = 1; renderPage();
24546      };
24547
24548      renderPage();
24549
24550      // ── Export helpers ────────────────────────────────────────────────────
24551      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
24552      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
24553      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);}
24554      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;');}
24555      function slocXlsx(fname,sheet,hdrs,rows){
24556        var enc=new TextEncoder();
24557        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;}
24558        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;}
24559        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
24560        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
24561        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24562        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;}
24563        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];}
24564        var rx='<row r="1">';
24565        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
24566        rx+='</row>';
24567        rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn),num=cell!==''&&cell!=null&&!isNaN(Number(cell))&&isFinite(Number(cell))&&/^[+\-]?\d/.test(String(cell));rx+=num?'<c r="'+ref+'"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});
24568        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
24569        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';
24570        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>';
24571        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
24572        var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
24573          '_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>',
24574          '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>',
24575          '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>',
24576          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
24577        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'];
24578        var zparts=[],zcds=[],zoff=0,znf=0;
24579        order.forEach(function(name){
24580          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
24581          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]);
24582          var entry=new Uint8Array(lha.length+nb.length+sz);
24583          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
24584          zparts.push(entry);
24585          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));
24586          var cde=new Uint8Array(cda.length+nb.length);
24587          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
24588          zcds.push(cde);zoff+=entry.length;znf++;
24589        });
24590        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
24591        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]);
24592        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
24593        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
24594        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
24595        zout.set(new Uint8Array(ea),zpos);
24596        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
24597      }
24598
24599      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
24600      function getHistoryRows(){var r=[];document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){r.push([tr.getAttribute('data-timestamp')||'',tr.getAttribute('data-project')||'',tr.getAttribute('data-run')||'',tr.getAttribute('data-files')||'',tr.getAttribute('data-skipped')||'',tr.getAttribute('data-code')||'',tr.getAttribute('data-comments')||'',tr.getAttribute('data-blank')||'',tr.getAttribute('data-branch')||'',tr.getAttribute('data-commit')||'']);});return r;}
24601      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
24602      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
24603
24604      var csvBtn = document.getElementById('export-csv-btn');
24605      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
24606      var xlsBtn = document.getElementById('export-xls-btn');
24607      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
24608
24609      // ── Remaining CSP-safe event bindings ────────────────────────────────
24610      (function wireEvents() {
24611        var el;
24612        el = document.getElementById('reset-view-btn');
24613        if (el) el.addEventListener('click', window.resetView);
24614        el = document.getElementById('project-filter');
24615        if (el) el.addEventListener('input', window.applyFilters);
24616        el = document.getElementById('branch-filter');
24617        if (el) el.addEventListener('change', window.applyFilters);
24618        el = document.getElementById('per-page-sel');
24619        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
24620        el = document.getElementById('add-watched-btn');
24621        if (el) el.addEventListener('click', function() {
24622          fetch('/pick-directory?kind=reports')
24623            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
24624            .then(function(data) {
24625              if (!data.cancelled && data.selected_path) {
24626                var form = document.createElement('form');
24627                form.method = 'POST';
24628                form.action = '/watched-dirs/add';
24629                var ri = document.createElement('input');
24630                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
24631                var fi = document.createElement('input');
24632                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
24633                form.appendChild(ri); form.appendChild(fi);
24634                document.body.appendChild(form);
24635                form.submit();
24636              }
24637            })
24638            .catch(function(e) { alert('Could not open folder picker: ' + e); });
24639        });
24640      })();
24641
24642      (function randomizeWatermarks() {
24643        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
24644        if (!wms.length) return;
24645        var placed = [];
24646        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;}
24647        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];}
24648        var half=Math.floor(wms.length/2);
24649        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;});
24650      })();
24651
24652      (function spawnCodeParticles() {
24653        var container = document.getElementById('code-particles');
24654        if (!container) return;
24655        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'];
24656        for (var i = 0; i < 38; i++) {
24657          (function(idx) {
24658            var el = document.createElement('span');
24659            el.className = 'code-particle';
24660            el.textContent = snippets[idx % snippets.length];
24661            var left = Math.random() * 94 + 2;
24662            var top = Math.random() * 88 + 6;
24663            var dur = (Math.random() * 10 + 9).toFixed(1);
24664            var delay = (Math.random() * 18).toFixed(1);
24665            var rot = (Math.random() * 26 - 13).toFixed(1);
24666            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24667            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';
24668            container.appendChild(el);
24669          })(i);
24670        }
24671      })();
24672    })();
24673  </script>
24674  <script nonce="{{ csp_nonce }}">
24675  (function(){
24676    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'}];
24677    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);});}
24678    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24679    function init(){
24680      var btn=document.getElementById('settings-btn');if(!btn)return;
24681      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24682      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>';
24683      document.body.appendChild(m);
24684      var g=document.getElementById('scheme-grid');
24685      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);});
24686      var cl=document.getElementById('settings-close');
24687      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);
24688      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');});
24689      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24690      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24691    }
24692    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24693  }());
24694  </script>
24695  <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 }} — 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>
24696</body>
24697</html>
24698"##,
24699    ext = "html"
24700)]
24701struct HistoryTemplate {
24702    version: &'static str,
24703    entries: Vec<HistoryEntryRow>,
24704    total_scans: usize,
24705    linked_count: usize,
24706    browse_error: Option<String>,
24707    watched_dirs: Vec<String>,
24708    csp_nonce: String,
24709    server_mode: bool,
24710}
24711
24712// ── CompareSelectTemplate ──────────────────────────────────────────────────────
24713
24714#[derive(Template)]
24715#[template(
24716    source = r##"
24717<!doctype html>
24718<html lang="en">
24719<head>
24720  <meta charset="utf-8">
24721  <meta name="viewport" content="width=device-width, initial-scale=1">
24722  <title>OxideSLOC | Compare Scans</title>
24723  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24724  <style nonce="{{ csp_nonce }}">
24725    :root {
24726      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24727      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24728      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24729      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24730      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
24731    }
24732    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24733    *{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;}
24734    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24735    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24736    .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);}
24737    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24738    .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));}
24739    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24740    .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;}
24741    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24742    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24743    @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; } }
24744    .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;}
24745    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24746    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24747    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24748    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24749    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24750    .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;}
24751    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24752    .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);}
24753    .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;}
24754    .settings-close:hover{color:var(--text);background:var(--surface-2);}
24755    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24756    .settings-modal-body{padding:14px 16px 16px;}
24757    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24758    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24759    .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;}
24760    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24761    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24762    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24763    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24764    .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;}
24765    .tz-select:focus{border-color:var(--oxide);}
24766    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24767    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24768    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24769    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24770    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24771    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
24772    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
24773    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24774    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24775    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24776    .per-page-label{font-size:13px;color:var(--muted);}
24777    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;}
24778    .filter-input{min-width:180px;cursor:text;}
24779    .table-wrap{width:100%;overflow-x:auto;}
24780    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
24781    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;}
24782    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24783    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24784    #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;}
24785    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
24786    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
24787    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
24788    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
24789    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
24790    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
24791    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
24792    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
24793    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
24794    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24795    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24796    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24797    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24798    tr:last-child td{border-bottom:none;}
24799    tr.selected td{background:var(--sel-bg);}
24800    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
24801    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
24802    tr{cursor:pointer;}
24803    tr.row-locked{opacity:.35;cursor:not-allowed;}
24804    tr.row-locked td{pointer-events:none;}
24805    .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;}
24806    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
24807    .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;}
24808    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
24809    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
24810    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
24811    .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);}
24812    .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);}
24813    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24814    .metric-num{font-weight:700;color:var(--text);}
24815    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24816    .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;}
24817    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
24818    .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;}
24819    .btn:hover{background:var(--line);}
24820    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
24821    .btn.primary:hover{opacity:.9;}
24822    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
24823    .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;}
24824    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24825    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24826    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24827    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24828    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24829    .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;}
24830    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24831    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24832    .watched-chip-rm:hover{color:var(--oxide);}
24833    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24834    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24835    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24836    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24837    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
24838    .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;}
24839    .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;}
24840    .btn-back:hover{background:var(--line);}
24841    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24842    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24843    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24844    .pagination-info{font-size:13px;color:var(--muted);}
24845    .pagination-btns{display:flex;gap:6px;}
24846    .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;}
24847    .pg-btn:hover:not(:disabled){background:var(--line);}
24848    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24849    .pg-btn:disabled{opacity:.35;cursor:default;}
24850    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
24851    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24852    .site-footer a{color:var(--muted);}
24853    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24854    .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;}
24855    .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;}
24856    .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;}
24857    @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));}}
24858    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24859    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24860    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
24861    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24862    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24863    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24864    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
24865    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24866    .stat-chip:hover .stat-chip-tip{opacity:1;}
24867    .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;}
24868    .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;}
24869    .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%;}
24870    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
24871    .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;}
24872    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
24873    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
24874    .hidden{display:none!important;}
24875    .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%;}
24876    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
24877    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
24878    .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;}
24879    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
24880    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
24881    .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;}
24882    .scope-option:hover{background:var(--line);}
24883    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
24884    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
24885    .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;}
24886    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
24887    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
24888    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
24889    .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;}
24890  </style>
24891</head>
24892<body>
24893  <div class="background-watermarks" aria-hidden="true">
24894    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24895    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24896    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24897    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24898    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24899    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24900  </div>
24901  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24902  <div class="top-nav">
24903    <div class="top-nav-inner">
24904      <a class="brand" href="/">
24905        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24906        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
24907      </a>
24908      <div class="nav-right">
24909        <a class="nav-pill" href="/">Home</a>
24910        <div class="nav-dropdown">
24911          <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>
24912          <div class="nav-dropdown-menu">
24913            <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>
24914          </div>
24915        </div>
24916        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24917        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24918        <div class="nav-dropdown">
24919          <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>
24920          <div class="nav-dropdown-menu">
24921            <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>
24922          </div>
24923        </div>
24924        <div class="server-status-wrap" id="server-status-wrap">
24925          <div class="nav-pill server-online-pill" id="server-status-pill">
24926            <span class="status-dot" id="status-dot"></span>
24927            <span id="server-status-label">Server</span>
24928            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24929          </div>
24930          <div class="server-status-tip">
24931            OxideSLOC is running — accessible on your network.
24932            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24933          </div>
24934        </div>
24935        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24936          <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>
24937        </button>
24938        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24939          <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>
24940          <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>
24941        </button>
24942      </div>
24943    </div>
24944  </div>
24945
24946  <div class="page">
24947    <div class="watched-bar">
24948      <div class="watched-bar-left">
24949        <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>
24950        <span class="watched-label">Watched Folders</span>
24951        <div class="watched-chips">
24952          {% if server_mode %}
24953          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24954          {% else %}
24955          {% for dir in watched_dirs %}
24956          <span class="watched-chip">
24957            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24958            <form method="POST" action="/watched-dirs/remove" style="display:contents">
24959              <input type="hidden" name="folder_path" value="{{ dir }}">
24960              <input type="hidden" name="redirect_to" value="/compare-scans">
24961              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
24962            </form>
24963          </span>
24964          {% endfor %}
24965          {% if watched_dirs.is_empty() %}
24966          <span class="watched-none">No folders watched — click Choose to add one</span>
24967          {% endif %}
24968          {% endif %}
24969        </div>
24970      </div>
24971      {% if !server_mode %}
24972      <div class="watched-bar-right">
24973        <button type="button" class="btn" id="add-watched-btn">
24974          <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>
24975          Choose
24976        </button>
24977        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24978          <input type="hidden" name="redirect_to" value="/compare-scans">
24979          <button type="submit" class="btn">&#8635; Refresh</button>
24980        </form>
24981      </div>
24982      {% endif %}
24983    </div>
24984    {% if total_scans > 0 %}
24985    <div class="summary-strip">
24986      <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>
24987      <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>
24988      <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>
24989      <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>
24990    </div>
24991    {% endif %}
24992    <section class="panel">
24993      <div class="panel-header">
24994        <div>
24995          <h1>Compare Scans</h1>
24996          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
24997        </div>
24998        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
24999          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
25000            <button class="btn primary" id="compare-btn" disabled>
25001              <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>
25002              Compare <span class="sel-count" id="sel-count">0</span> Selected
25003            </button>
25004          </div>
25005        </div>
25006      </div>
25007
25008      {% if entries.is_empty() %}
25009      <div class="empty-state">
25010        <strong>No scans yet</strong>
25011        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.
25012      </div>
25013      {% else %}
25014      <div class="filter-row">
25015        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
25016        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
25017        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
25018      </div>
25019      <div class="scope-panel hidden" id="scope-panel">
25020        <div class="scope-panel-label">
25021          <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>
25022          Compare scope — choose what to include
25023        </div>
25024        <div class="scope-options" id="scope-options"></div>
25025      </div>
25026      {% if total_scans > 0 %}
25027      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
25028        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
25029          <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>
25030          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
25031        </div>
25032      </div>
25033      {% endif %}
25034      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
25035        <span class="compare-all-label">
25036          <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>
25037          Quick Compare All
25038        </span>
25039      </div>
25040      <div class="table-wrap">
25041        <table id="compare-table">
25042          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
25043          <thead>
25044            <tr id="compare-thead">
25045              <th><div class="col-resize-handle"></div></th>
25046              <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>
25047              <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>
25048              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
25049              <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>
25050              <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>
25051              <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>
25052              <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>
25053              <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>
25054              <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>
25055              <th>Submodules<div class="col-resize-handle"></div></th>
25056            </tr>
25057          </thead>
25058          <tbody id="compare-tbody">
25059            {% for entry in entries %}
25060            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
25061                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
25062                data-project="{{ entry.project_label }}"
25063                data-files="{{ entry.files_analyzed }}"
25064                data-code="{{ entry.code_lines }}"
25065                data-comments="{{ entry.comment_lines }}"
25066                data-blank="{{ entry.blank_lines }}"
25067                data-branch="{{ entry.git_branch }}"
25068                data-commit="{{ entry.git_commit }}"
25069                data-submodules="{{ entry.submodule_names_csv }}">
25070              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
25071              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
25072              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
25073              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
25074              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
25075              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
25076              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
25077              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
25078              <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>
25079              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
25080              <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>
25081            </tr>
25082            {% endfor %}
25083          </tbody>
25084        </table>
25085      </div>
25086      <div class="pagination">
25087        <span class="pagination-info" id="pagination-info"></span>
25088        <div class="pagination-btns" id="pagination-btns"></div>
25089        <div class="flex-row">
25090          <span class="per-page-label">Show</span>
25091          <select class="per-page" id="per-page-sel">
25092            <option value="10">10 per page</option>
25093            <option value="25" selected>25 per page</option>
25094            <option value="50">50 per page</option>
25095            <option value="100">100 per page</option>
25096          </select>
25097          <span class="per-page-label" id="page-range-label"></span>
25098        </div>
25099      </div>
25100      {% endif %}
25101    </section>
25102  </div>
25103
25104  <footer class="site-footer">
25105    local code analysis - metrics, history and reports
25106    &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>
25107    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25108    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25109    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25110    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25111  </footer>
25112
25113  <script nonce="{{ csp_nonce }}">
25114    (function () {
25115      // ── Theme ──────────────────────────────────────────────────────────────
25116      var storageKey = 'oxide-sloc-theme';
25117      var body = document.body;
25118      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25119      var toggle = document.getElementById('theme-toggle');
25120      if (toggle) toggle.addEventListener('click', function () {
25121        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25122        body.classList.toggle('dark-theme', next === 'dark');
25123        try { localStorage.setItem(storageKey, next); } catch(e) {}
25124      });
25125
25126      // ── State ─────────────────────────────────────────────────────────────
25127      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
25128      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
25129      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
25130      window._allCompareRows = allRows;
25131
25132      // ── Stat chips ────────────────────────────────────────────────────────
25133      (function() {
25134        var projects = {}, latestTs = '', latestRow = null;
25135        allRows.forEach(function(r) {
25136          var p = r.dataset.project || ''; if (p) projects[p] = true;
25137          var ts = r.dataset.timestamp || '';
25138          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
25139        });
25140        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();}
25141        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>':'');}
25142        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
25143        if (latestRow) {
25144          setChipVal('agg-code', latestRow.dataset.code);
25145          setChipVal('agg-files', latestRow.dataset.files);
25146        }
25147      })();
25148
25149      // ── Branch filter population ──────────────────────────────────────────
25150      (function() {
25151        var branches = {};
25152        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
25153        var sel = document.getElementById('branch-filter');
25154        if (sel) Object.keys(branches).sort().forEach(function(b) {
25155          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
25156        });
25157      })();
25158
25159      // ── Filter ────────────────────────────────────────────────────────────
25160      function getFilteredRows() {
25161        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
25162        var branch = ((document.getElementById('branch-filter') || {}).value || '');
25163        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
25164          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
25165          if (branch && (r.dataset.branch || '') !== branch) return false;
25166          return true;
25167        });
25168      }
25169
25170      // ── Pagination ────────────────────────────────────────────────────────
25171      function renderPage() {
25172        var filtered = getFilteredRows();
25173        var total = filtered.length;
25174        var totalPages = Math.max(1, Math.ceil(total / perPage));
25175        currentPage = Math.min(currentPage, totalPages);
25176        var start = (currentPage - 1) * perPage;
25177        var end = Math.min(start + perPage, total);
25178        var shown = {};
25179        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
25180        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
25181          r.style.display = shown[r.dataset.run] ? '' : 'none';
25182        });
25183        var rl = document.getElementById('page-range-label');
25184        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
25185        var info = document.getElementById('pagination-info');
25186        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
25187        var btns = document.getElementById('pagination-btns');
25188        if (!btns) return;
25189        btns.innerHTML = '';
25190        function makeBtn(lbl, pg, active, disabled) {
25191          var b = document.createElement('button');
25192          b.className = 'pg-btn' + (active ? ' active' : '');
25193          b.textContent = lbl; b.disabled = disabled;
25194          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
25195          return b;
25196        }
25197        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
25198        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
25199        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
25200        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
25201      }
25202
25203      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
25204      window.applyFilters = function() { currentPage = 1; renderPage(); };
25205
25206      // ── Sorting ───────────────────────────────────────────────────────────
25207      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
25208      function doSort(col, type, order) {
25209        var tbody = document.getElementById('compare-tbody');
25210        if (!tbody) return;
25211        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25212        rows.sort(function(a, b) {
25213          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
25214          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
25215          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
25216          return va < vb ? 1 : va > vb ? -1 : 0;
25217        });
25218        rows.forEach(function(r) { tbody.appendChild(r); });
25219        currentPage = 1; renderPage();
25220      }
25221      sortHeaders.forEach(function(th) {
25222        th.addEventListener('click', function(e) {
25223          if (e.target.classList.contains('col-resize-handle')) return;
25224          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
25225          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
25226          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25227          th.classList.add('sort-' + sortOrder);
25228          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
25229          doSort(col, type, sortOrder);
25230        });
25231      });
25232
25233      // Apply default sort (timestamp desc) on initial load
25234      (function() {
25235        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
25236        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
25237      })();
25238
25239      // ── Column resize ─────────────────────────────────────────────────────
25240      (function() {
25241        var table = document.getElementById('compare-table');
25242        if (!table) return;
25243        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
25244        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
25245        ths.forEach(function(th, i) {
25246          var handle = th.querySelector('.col-resize-handle');
25247          if (!handle || !cols[i]) return;
25248          var startX, startW;
25249          handle.addEventListener('mousedown', function(e) {
25250            e.stopPropagation(); e.preventDefault();
25251            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
25252            handle.classList.add('dragging');
25253            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
25254            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
25255            document.addEventListener('mousemove', onMove);
25256            document.addEventListener('mouseup', onUp);
25257          });
25258        });
25259      })();
25260
25261      // ── Reset view ────────────────────────────────────────────────────────
25262      window.resetView = function() {
25263        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
25264        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
25265        sortCol = null; sortOrder = 'asc';
25266        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25267        var tbody = document.getElementById('compare-tbody');
25268        if (tbody) {
25269          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25270          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
25271          rows.forEach(function(r) { tbody.appendChild(r); });
25272        }
25273        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
25274        var table = document.getElementById('compare-table');
25275        currentPage = 1; renderPage();
25276        currentPage = 1; renderPage();
25277      };
25278
25279      renderPage();
25280      buildCompareAllBar();
25281
25282      // ── Row selection state ───────────────────────────────────────────────
25283      var selected = [];
25284      var lockedProject = null; // project label of first selected scan
25285
25286      function updateCompareBtn() {
25287        var btn = document.getElementById('compare-btn');
25288        var cnt = document.getElementById('sel-count');
25289        if (!btn) return;
25290        btn.disabled = selected.length < 2;
25291        if (cnt) cnt.textContent = selected.length;
25292      }
25293
25294      function applyProjectLock() {
25295        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25296        allRows.forEach(function(r) {
25297          if (lockedProject === null) {
25298            r.classList.remove('row-locked');
25299          } else {
25300            var proj = r.dataset.project || '';
25301            if (proj !== lockedProject) {
25302              r.classList.add('row-locked');
25303            } else {
25304              r.classList.remove('row-locked');
25305            }
25306          }
25307        });
25308      }
25309
25310      function toggleRow(row) {
25311        if (row.classList.contains('row-locked')) return;
25312        var vid = row.dataset.vid || row.dataset.run;
25313        var idx = selected.indexOf(vid);
25314        if (idx >= 0) {
25315          selected.splice(idx, 1);
25316          row.classList.remove('selected');
25317          var b = document.getElementById('badge-' + vid);
25318          if (b) b.textContent = '';
25319          // Release project lock if nothing selected
25320          if (selected.length === 0) lockedProject = null;
25321        } else {
25322          // Set project lock on first selection
25323          if (selected.length === 0) lockedProject = row.dataset.project || null;
25324          selected.push(vid);
25325          row.classList.add('selected');
25326        }
25327        selected.forEach(function(v, i) {
25328          var b = document.getElementById('badge-' + v);
25329          if (b) b.textContent = i + 1;
25330        });
25331        applyProjectLock();
25332        updateCompareBtn();
25333        buildScopePanel();
25334      }
25335
25336      // ── Compare-All bar ───────────────────────────────────────────────────
25337      function buildCompareAllBar() {
25338        var bar = document.getElementById('compare-all-bar');
25339        if (!bar) return;
25340        // Group all rows by project label.
25341        var groups = {};
25342        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25343        // Use all rows from the source data (not just visible).
25344        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25345        // We need ALL rows across all pages, not just the rendered ones.
25346        // Use the underlying allRows array that the pagination JS also uses.
25347        var sourceRows = window._allCompareRows || allRowsAll;
25348        sourceRows.forEach(function(r) {
25349          var proj = r.dataset.project || '';
25350          var vid = r.dataset.vid || r.dataset.run || '';
25351          if (!proj || !vid) return;
25352          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
25353          groups[proj].ids.push(vid);
25354          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
25355        });
25356        // Build buttons for each project with >= 2 scans.
25357        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
25358        if (!keys.length) { bar.style.display = 'none'; return; }
25359        bar.style.display = 'flex';
25360        // Remove old buttons (keep label).
25361        var oldBtns = bar.querySelectorAll('.compare-all-btn');
25362        oldBtns.forEach(function(b) { b.remove(); });
25363        keys.sort();
25364        keys.forEach(function(proj) {
25365          var g = groups[proj];
25366          var btn = document.createElement('button');
25367          btn.className = 'compare-all-btn';
25368          btn.type = 'button';
25369          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
25370          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
25371          btn.addEventListener('click', function() {
25372            // Sort ids by timestamp (ascending).
25373            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
25374            pairs.sort(function(a, b) { return a.ts - b.ts; });
25375            var sorted = pairs.map(function(p) { return p.id; });
25376            if (sorted.length === 2) {
25377              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
25378            } else {
25379              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
25380            }
25381          });
25382          bar.appendChild(btn);
25383        });
25384      }
25385
25386      // ── Scope panel ───────────────────────────────────────────────────────
25387      var selectedScope = 'all';
25388
25389      function buildScopePanel() {
25390        var panel = document.getElementById('scope-panel');
25391        var opts = document.getElementById('scope-options');
25392        if (!panel || !opts) return;
25393        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25394
25395        // Collect union of submodules from all selected rows.
25396        var allSubs = {};
25397        selected.forEach(function(vid) {
25398          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
25399          if (!row) return;
25400          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
25401        });
25402        var subList = Object.keys(allSubs).sort();
25403        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25404
25405        panel.classList.remove('hidden');
25406        opts.innerHTML = '';
25407
25408        function makeOption(value, label, title) {
25409          var div = document.createElement('div');
25410          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
25411          div.dataset.scopeValue = value;
25412          if (title) div.title = title;
25413          var radio = document.createElement('span');
25414          radio.className = 'scope-option-radio';
25415          var lbl = document.createElement('span');
25416          lbl.textContent = label;
25417          div.appendChild(radio);
25418          div.appendChild(lbl);
25419          div.addEventListener('click', function() {
25420            selectedScope = value;
25421            opts.querySelectorAll('.scope-option').forEach(function(o) {
25422              o.classList.toggle('selected', o.dataset.scopeValue === value);
25423            });
25424          });
25425          return div;
25426        }
25427
25428        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
25429        var sep = document.createElement('span');
25430        sep.className = 'scope-option-sep';
25431        opts.appendChild(sep);
25432        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
25433        subList.forEach(function(s) {
25434          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
25435        });
25436      }
25437
25438      function doCompare() {
25439        if (selected.length < 2) return;
25440        if (selected.length === 2) {
25441          // Two-scan delta (existing flow with scope support).
25442          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
25443          if (selectedScope === 'super') url += '&scope=super';
25444          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25445          window.location.href = url;
25446        } else {
25447          // Multi-scan timeline (N >= 3) — pass scope params too.
25448          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
25449          if (selectedScope === 'super') url += '&scope=super';
25450          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25451          window.location.href = url;
25452        }
25453      }
25454
25455      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
25456      var cbtn = document.getElementById('compare-btn');
25457      if (cbtn) cbtn.addEventListener('click', doCompare);
25458      var pfEl = document.getElementById('project-filter');
25459      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
25460      var bfEl = document.getElementById('branch-filter');
25461      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
25462      var rvBtn = document.getElementById('reset-view-btn');
25463      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
25464      var ppSel = document.getElementById('per-page-sel');
25465      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
25466
25467      var cmpTbody = document.getElementById('compare-tbody');
25468      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
25469        var row = e.target.closest('.compare-row');
25470        if (row) toggleRow(row);
25471      });
25472
25473      (function randomizeWatermarks() {
25474        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25475        if (!wms.length) return;
25476        var placed = [];
25477        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;}
25478        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];}
25479        var half=Math.floor(wms.length/2);
25480        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;});
25481      })();
25482
25483      (function spawnCodeParticles() {
25484        var container = document.getElementById('code-particles');
25485        if (!container) return;
25486        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'];
25487        for (var i = 0; i < 38; i++) {
25488          (function(idx) {
25489            var el = document.createElement('span');
25490            el.className = 'code-particle';
25491            el.textContent = snippets[idx % snippets.length];
25492            var left = Math.random() * 94 + 2;
25493            var top = Math.random() * 88 + 6;
25494            var dur = (Math.random() * 10 + 9).toFixed(1);
25495            var delay = (Math.random() * 18).toFixed(1);
25496            var rot = (Math.random() * 26 - 13).toFixed(1);
25497            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25498            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';
25499            container.appendChild(el);
25500          })(i);
25501        }
25502      })();
25503
25504      // ── Watched folder picker ─────────────────────────────────────────────
25505      (function() {
25506        var btn = document.getElementById('add-watched-btn');
25507        if (!btn) return;
25508        btn.addEventListener('click', function() {
25509          fetch('/pick-directory?kind=reports')
25510            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
25511            .then(function(data) {
25512              if (!data.cancelled && data.selected_path) {
25513                var form = document.createElement('form');
25514                form.method = 'POST';
25515                form.action = '/watched-dirs/add';
25516                var ri = document.createElement('input');
25517                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
25518                var fi = document.createElement('input');
25519                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
25520                form.appendChild(ri); form.appendChild(fi);
25521                document.body.appendChild(form);
25522                form.submit();
25523              }
25524            })
25525            .catch(function(e) { alert('Could not open folder picker: ' + e); });
25526        });
25527      })();
25528
25529      // ── Submodule chip truncation ─────────────────────────────────────────
25530      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
25531        var chips = cell.querySelectorAll('.submod-chip');
25532        var MAX = 4;
25533        if (chips.length <= MAX) return;
25534        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
25535        var badge = document.createElement('span');
25536        badge.className = 'submod-overflow-badge';
25537        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
25538        badge.textContent = '+' + (chips.length - MAX) + ' more';
25539        cell.appendChild(badge);
25540        cell.style.maxHeight = 'none';
25541      });
25542    })();
25543  </script>
25544  <script nonce="{{ csp_nonce }}">
25545  (function(){
25546    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'}];
25547    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);});}
25548    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25549    function init(){
25550      var btn=document.getElementById('settings-btn');if(!btn)return;
25551      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25552      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>';
25553      document.body.appendChild(m);
25554      var g=document.getElementById('scheme-grid');
25555      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);});
25556      var cl=document.getElementById('settings-close');
25557      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);
25558      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');});
25559      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25560      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25561    }
25562    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25563  }());
25564  </script>
25565  <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]';
25566  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;}
25567  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>
25568</body>
25569</html>
25570"##,
25571    ext = "html"
25572)]
25573struct CompareSelectTemplate {
25574    version: &'static str,
25575    entries: Vec<HistoryEntryRow>,
25576    total_scans: usize,
25577    watched_dirs: Vec<String>,
25578    csp_nonce: String,
25579    server_mode: bool,
25580}
25581
25582// ── CompareTemplate ────────────────────────────────────────────────────────────
25583
25584#[derive(Template)]
25585#[template(
25586    source = r##"
25587<!doctype html>
25588<html lang="en">
25589<head>
25590  <meta charset="utf-8">
25591  <meta name="viewport" content="width=device-width, initial-scale=1">
25592  <title>OxideSLOC | Scan Delta</title>
25593  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25594  <style nonce="{{ csp_nonce }}">
25595    :root {
25596      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
25597      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
25598      --nav:#283790; --nav-2:#013e6b;
25599      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
25600      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
25601      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
25602    }
25603    body.dark-theme {
25604      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
25605      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
25606    }
25607    *{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;}
25608    .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);}
25609    .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;}
25610    .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));}
25611    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25612    .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;}
25613    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
25614    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25615    @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; } }
25616    .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;}
25617    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25618    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25619    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25620    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25621    .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;}
25622    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25623    .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);}
25624    .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;}
25625    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25626    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25627    .settings-modal-body{padding:14px 16px 16px;}
25628    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25629    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25630    .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;}
25631    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25632    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25633    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25634    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25635    .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;}
25636    .tz-select:focus{border-color:var(--oxide);}
25637    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
25638    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25639    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
25640    .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;}
25641    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
25642    .hero-body{display:block;}
25643    .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;}
25644    .btn-back:hover{background:var(--line);}
25645    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
25646    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
25647    .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;}
25648    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
25649    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;}
25650    .muted{color:var(--muted);font-size:14px;}
25651    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
25652    .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;}
25653    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
25654    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
25655    .vpill-arrow{font-size:20px;color:var(--muted);}
25656    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
25657    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
25658    .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;}
25659    .delta-card.delta-card-wide{padding:22px 24px;}
25660    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
25661    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
25662    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
25663    .delta-card-from{font-size:15px;color:var(--muted);}
25664    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
25665    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
25666    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
25667    .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%;}
25668    .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;}
25669    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
25670    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
25671    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
25672    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
25673    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
25674    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
25675    .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;}
25676    .meta-card-commit:hover{color:var(--oxide);}
25677    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
25678    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
25679    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
25680    .meta-value{color:var(--text);font-size:13px;}
25681    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
25682    .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;}
25683    .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);}
25684    .delta-card:hover .dc-tip{display:block;}
25685    .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;}
25686    .export-btn:hover{background:var(--line);}
25687    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
25688    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
25689    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
25690    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
25691    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
25692    .delta-card-change.zero{color:var(--muted);background:transparent;}
25693    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
25694    .delta-card-pct.pos{color:var(--pos);}
25695    .delta-card-pct.neg{color:var(--neg);}
25696    .delta-card-pct.zero{color:var(--muted);}
25697    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
25698    .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;}
25699    .insight-card.insight-flag{border-color:var(--oxide);}
25700    .insight-card:hover .dc-tip{display:block;}
25701    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
25702    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
25703    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
25704    .insight-label.flag{color:var(--oxide);}
25705    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
25706    .insight-val.pos{color:var(--pos);}
25707    .insight-val.neg{color:var(--neg);}
25708    .insight-val.high{color:#c0392a;}
25709    .insight-val.med{color:#926000;}
25710    .insight-val.low{color:var(--pos);}
25711    body.dark-theme .insight-val.high{color:#ff6b6b;}
25712    body.dark-theme .insight-val.med{color:#f0c060;}
25713    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
25714    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
25715    .fc-row{display:flex;align-items:center;gap:8px;}
25716    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
25717    .fc-label{color:var(--muted);}
25718    .fc-modified .fc-count{color:#926000;}
25719    .fc-added .fc-count{color:var(--pos);}
25720    .fc-removed .fc-count{color:var(--neg);}
25721    .fc-unchanged .fc-count{color:var(--muted);}
25722    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
25723    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
25724    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
25725    .chip.modified{background:#fff2d8;color:#926000;}
25726    .chip.added{background:#e8f5ed;color:#1a8f47;}
25727    .chip.removed{background:#fdeaea;color:#b33b3b;}
25728    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
25729    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
25730    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
25731    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
25732    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
25733    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
25734    .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;}
25735    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
25736    .tab-btn:hover:not(.active){background:var(--line);}
25737    .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;}
25738    .btn-reset:hover{background:var(--line);}
25739    .table-wrap{width:100%;overflow-x:auto;}
25740    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
25741    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);}
25742    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
25743    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
25744    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
25745    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
25746    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
25747    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
25748    tr:last-child td{border-bottom:none;}
25749    tr:hover td{background:var(--surface-2);}
25750    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
25751    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
25752    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
25753    tr.row-added td{background:rgba(26,143,71,0.04);}
25754    tr.row-removed td{background:rgba(179,59,59,0.06);}
25755    tr.row-modified td{background:rgba(146,96,0,0.04);}
25756    tr.row-unchanged td{color:var(--muted);}
25757    tr.row-unchanged .status-badge{opacity:.65;}
25758    .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;}
25759    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
25760    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
25761    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
25762    .status-badge.modified{background:#fff2d8;color:#926000;}
25763    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
25764    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
25765    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
25766    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
25767    .delta-val{font-weight:700;}
25768    .delta-val.pos{color:var(--pos);}
25769    .delta-val.neg{color:var(--neg);}
25770    .delta-val.zero{color:var(--muted);}
25771    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
25772    .from-to strong{color:var(--text);font-weight:700;}
25773    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
25774    .from-to .ft-absent{color:var(--muted);font-weight:600;}
25775    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25776    .site-footer a{color:var(--muted);}
25777    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;}
25778    body.pdf-mode{background:#fff!important;}
25779    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
25780    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
25781    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25782    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25783    .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;}
25784    .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;}
25785    .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;}
25786    @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));}}
25787    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
25788    .path-link:hover{color:var(--oxide-2);}
25789    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
25790    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
25791    a.vpill-id:hover{color:var(--oxide);}
25792    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
25793    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
25794    .pagination-info{font-size:13px;color:var(--muted);}
25795    .pagination-btns{display:flex;gap:6px;}
25796    .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;}
25797    .pg-btn:hover:not(:disabled){background:var(--line);}
25798    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25799    .pg-btn:disabled{opacity:.35;cursor:default;}
25800    .per-page-label{font-size:13px;color:var(--muted);}
25801    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;}
25802    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25803    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
25804    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
25805    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
25806    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
25807    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
25808    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
25809    .tab-btn.tab-unchanged{color:var(--muted);}
25810    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
25811    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
25812    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
25813    .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;}
25814    .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;}
25815    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
25816    .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;}
25817    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
25818    .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;}
25819    .submod-scope-btn:hover{background:var(--line);}
25820    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25821    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
25822    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
25823    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
25824    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
25825    body.dark-theme .ic-card{background:var(--surface-2);}
25826    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
25827    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
25828    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
25829    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
25830    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
25831    .ic-cb{cursor:pointer;transition:filter .15s;}.ic-cb:hover{filter:brightness(1.12);}
25832    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
25833    .ic-card-h2-row .ic-card-h2{margin:0;}
25834    .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;}
25835    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25836    .chart-metric-btn:hover:not(.active){background:var(--line);}
25837    .chart-wrap{width:100%;overflow-x:auto;}
25838    #cmp-tl-svg{display:block;width:100%;}
25839    .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);}
25840    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
25841    #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;}
25842  </style>
25843</head>
25844<body>
25845  <div class="background-watermarks" aria-hidden="true">
25846    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25847    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25848    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25849    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25850    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25851    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25852  </div>
25853  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25854  <div class="top-nav">
25855    <div class="top-nav-inner">
25856      <a class="brand" href="/">
25857        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25858        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
25859      </a>
25860      <div class="nav-right">
25861        <a class="nav-pill" href="/">Home</a>
25862        <div class="nav-dropdown">
25863          <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>
25864          <div class="nav-dropdown-menu">
25865            <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>
25866          </div>
25867        </div>
25868        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25869        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25870        <div class="nav-dropdown">
25871          <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>
25872          <div class="nav-dropdown-menu">
25873            <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>
25874          </div>
25875        </div>
25876        <div class="server-status-wrap" id="server-status-wrap">
25877          <div class="nav-pill server-online-pill" id="server-status-pill">
25878            <span class="status-dot" id="status-dot"></span>
25879            <span id="server-status-label">Server</span>
25880            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25881          </div>
25882          <div class="server-status-tip">
25883            OxideSLOC is running — accessible on your network.
25884            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25885          </div>
25886        </div>
25887        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25888          <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>
25889        </button>
25890        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25891          <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>
25892          <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>
25893        </button>
25894      </div>
25895    </div>
25896  </div>
25897
25898  <div class="page">
25899    <section class="hero">
25900      <div class="hero-header">
25901        <div>
25902          <h1 class="delta-title">Scan Delta</h1>
25903          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
25904          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
25905            {% if let Some(sub) = active_submodule %}
25906            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
25907            {% else if super_scope_active %}
25908            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
25909            {% else %}
25910            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
25911            {% endif %}
25912            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
25913          </div>
25914        </div>
25915        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
25916          <a class="btn-back" href="/compare-scans">
25917            <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>
25918            Compare Scans
25919          </a>
25920          <div class="export-group" style="margin-top:12px;">
25921            <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>
25922            <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>
25923          </div>
25924        </div>
25925      </div>
25926      {% if has_any_submodule_data %}
25927      <div class="submod-scope-bar">
25928        <span class="submod-scope-label">
25929          <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>
25930          Scope:
25931        </span>
25932        <div class="submod-scope-divider"></div>
25933        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
25934           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
25935           title="All files — super-repo and all submodules combined">Full scan</a>
25936        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
25937           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
25938           title="Only files that are not part of any submodule">Super-repo only</a>
25939        {% for sub in submodule_options %}
25940        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
25941           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
25942           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
25943        {% endfor %}
25944      </div>
25945      {% endif %}
25946      <div class="hero-body">
25947      <div class="meta-strip">
25948        <div class="delta-card delta-card-meta">
25949          <div class="meta-card-header">
25950            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
25951            <div class="meta-card-project-col">
25952              <div class="meta-card-project">{{ project_name }}</div>
25953              {% if has_any_submodule_data %}
25954              {% if let Some(sub) = active_submodule %}
25955              <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>
25956              {% else if super_scope_active %}
25957              <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>
25958              {% else %}
25959              <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>
25960              {% endif %}
25961              {% endif %}
25962            </div>
25963          </div>
25964          {% if !baseline_git_commit.is_empty() %}
25965          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
25966          {% else %}
25967          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
25968          {% endif %}
25969          <div class="meta-card-rows">
25970            <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>
25971            <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>
25972            <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>
25973            <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>
25974            {% if let Some(tags) = baseline_git_tags %}
25975            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25976            {% endif %}
25977          </div>
25978        </div>
25979        <div class="delta-card delta-card-meta">
25980          <div class="meta-card-header">
25981            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
25982            <div class="meta-card-project-col">
25983              <div class="meta-card-project">{{ project_name }}</div>
25984              {% if has_any_submodule_data %}
25985              {% if let Some(sub) = active_submodule %}
25986              <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>
25987              {% else if super_scope_active %}
25988              <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>
25989              {% else %}
25990              <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>
25991              {% endif %}
25992              {% endif %}
25993            </div>
25994          </div>
25995          {% if !current_git_commit.is_empty() %}
25996          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
25997          {% else %}
25998          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
25999          {% endif %}
26000          <div class="meta-card-rows">
26001            <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>
26002            <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>
26003            <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>
26004            <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>
26005            {% if let Some(tags) = current_git_tags %}
26006            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
26007            {% endif %}
26008          </div>
26009        </div>
26010      </div>
26011      <div class="delta-strip">
26012        <div class="delta-card">
26013          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
26014          <div class="delta-card-label">Code lines</div>
26015          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
26016          <div class="delta-card-to">{{ current_code_fmt }}</div>
26017          {% 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>
26018          {% 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>
26019          {% else %}<div class="delta-card-pct zero">±0%</div>
26020          {% endif %}
26021        </div>
26022        <div class="delta-card">
26023          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
26024          <div class="delta-card-label">Files analyzed</div>
26025          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
26026          <div class="delta-card-to">{{ current_files_fmt }}</div>
26027          {% 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>
26028          {% 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>
26029          {% else %}<div class="delta-card-pct zero">±0%</div>
26030          {% endif %}
26031        </div>
26032        <div class="delta-card">
26033          <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>
26034          <div class="delta-card-label">Comment lines</div>
26035          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
26036          <div class="delta-card-to">{{ current_comments_fmt }}</div>
26037          {% 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>
26038          {% 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>
26039          {% else %}<div class="delta-card-pct zero">±0%</div>
26040          {% endif %}
26041        </div>
26042        {{ coverage_delta_card|safe }}
26043        <div class="delta-card delta-card-wide">
26044          <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>
26045          <div class="delta-card-label">File changes</div>
26046          <div class="file-changes-grid">
26047            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
26048            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
26049            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
26050            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
26051          </div>
26052        </div>
26053      </div>
26054      <div class="insights-panel">
26055        <div class="insight-card">
26056          <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>
26057          <div class="insight-label">Lines Added</div>
26058          <div class="insight-val pos">+{{ code_lines_added }}</div>
26059          <div class="insight-sub">New or grown source lines</div>
26060        </div>
26061        <div class="insight-card">
26062          <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>
26063          <div class="insight-label">Lines Removed</div>
26064          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
26065          <div class="insight-sub">Deleted or shrunk source lines</div>
26066        </div>
26067        <div class="insight-card">
26068          <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>
26069          <div class="insight-label">Churn Rate</div>
26070          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
26071          <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>
26072        </div>
26073        {% if scope_flag %}
26074        <div class="insight-card insight-flag">
26075          <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>
26076          <div class="insight-label flag">Scope Signal</div>
26077          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
26078          <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>
26079        </div>
26080        {% endif %}
26081      </div>
26082      </div>
26083    </section>
26084
26085    <section class="panel" id="inline-charts-section">
26086      <div class="panel-title">Scan Delta Charts</div>
26087      <div class="ic-grid">
26088        <div class="ic-card" style="grid-column:span 2">
26089          <div class="ic-card-h2-row">
26090            <span class="ic-card-h2">Timeline</span>
26091            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
26092              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
26093              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
26094              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
26095              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
26096              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
26097            </div>
26098          </div>
26099          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
26100        </div>
26101        <div class="ic-card">
26102          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
26103          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;before)</span></div>
26104          <div id="ic-c1"></div>
26105        </div>
26106        <div class="ic-card" id="ic-lang-card">
26107          <div class="ic-card-h2">Language Code Delta</div>
26108          <div id="ic-c3"></div>
26109        </div>
26110        <div class="ic-card">
26111          <div class="ic-card-h2">Delta by Metric</div>
26112          <div id="ic-c2"></div>
26113        </div>
26114        <div class="ic-card">
26115          <div class="ic-card-h2">File Change Distribution</div>
26116          <div id="ic-c4"></div>
26117        </div>
26118      </div>
26119    </section>
26120
26121    <section class="panel">
26122      <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 }} files</span></div>
26123      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
26124        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
26125          <button class="tab-btn tab-all active" data-filter="all">All ({{ files_modified + files_added + files_removed + files_unchanged }})</button>
26126          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
26127          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
26128          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
26129          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
26130        </div>
26131        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
26132          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
26133          <div class="export-group">
26134            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
26135            <button type="button" class="export-btn" id="delta-csv-btn">
26136              <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>
26137              CSV
26138            </button>
26139            <button type="button" class="export-btn" id="delta-xls-btn">
26140              <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>
26141              Excel
26142            </button>
26143          </div>
26144        </div>
26145      </div>
26146
26147      <div class="table-wrap">
26148      <table id="delta-table">
26149        <colgroup>
26150          <col>
26151          <col>
26152          <col>
26153          <col>
26154          <col>
26155          <col>
26156          <col>
26157        </colgroup>
26158        <thead>
26159          <tr id="delta-thead">
26160            <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>
26161            <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>
26162            <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>
26163            <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>
26164            <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>
26165            <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>
26166            <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>
26167          </tr>
26168        </thead>
26169        <tbody id="delta-tbody">
26170          {% for row in file_rows %}
26171          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
26172              data-path="{{ row.relative_path }}"
26173              data-language="{{ row.language }}"
26174              data-baseline-code="{{ row.baseline_code }}"
26175              data-current-code="{{ row.current_code }}"
26176              data-code-delta="{{ row.code_delta_str }}"
26177              data-comment-delta="{{ row.comment_delta_str }}"
26178              data-total-delta="{{ row.total_delta_str }}"
26179              data-orig-idx="">
26180            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
26181            <td class="hide-sm">{{ row.language }}</td>
26182            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
26183            <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>
26184            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
26185            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
26186            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
26187          </tr>
26188          {% endfor %}
26189        </tbody>
26190      </table>
26191      </div>
26192      <div class="pagination">
26193        <span class="pagination-info" id="pg-range-label"></span>
26194        <div class="pagination-btns" id="pg-btns"></div>
26195        <div class="flex-row">
26196          <span class="per-page-label">Show</span>
26197          <select class="per-page" id="per-page-sel">
26198            <option value="10">10 per page</option>
26199            <option value="25" selected>25 per page</option>
26200            <option value="50">50 per page</option>
26201            <option value="100">100 per page</option>
26202          </select>
26203        </div>
26204      </div>
26205    </section>
26206  </div>
26207
26208  <div id="ic-tt"></div>
26209
26210  <footer class="site-footer">
26211    local code analysis - metrics, history and reports
26212    &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>
26213    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26214    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26215    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26216    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26217  </footer>
26218
26219  <script nonce="{{ csp_nonce }}">
26220    (function () {
26221      var storageKey = 'oxide-sloc-theme';
26222      var body = document.body;
26223      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26224      var toggle = document.getElementById('theme-toggle');
26225      if (toggle) toggle.addEventListener('click', function () {
26226        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26227        body.classList.toggle('dark-theme', next === 'dark');
26228        try { localStorage.setItem(storageKey, next); } catch(e) {}
26229      });
26230
26231      (function randomizeWatermarks() {
26232        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26233        if (!wms.length) return;
26234        var placed = [];
26235        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;}
26236        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];}
26237        var half=Math.floor(wms.length/2);
26238        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;});
26239      })();
26240
26241      (function spawnCodeParticles() {
26242        var container = document.getElementById('code-particles');
26243        if (!container) return;
26244        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'];
26245        for (var i = 0; i < 38; i++) {
26246          (function(idx) {
26247            var el = document.createElement('span');
26248            el.className = 'code-particle';
26249            el.textContent = snippets[idx % snippets.length];
26250            var left = Math.random() * 94 + 2;
26251            var top = Math.random() * 88 + 6;
26252            var dur = (Math.random() * 10 + 9).toFixed(1);
26253            var delay = (Math.random() * 18).toFixed(1);
26254            var rot = (Math.random() * 26 - 13).toFixed(1);
26255            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26256            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';
26257            container.appendChild(el);
26258          })(i);
26259        }
26260      })();
26261    })();
26262
26263    var activeStatusFilter = 'all';
26264    var deltaPerPage = 25, deltaCurrPage = 1;
26265
26266    function openFolder(path) {
26267      fetch('/open-path?path=' + encodeURIComponent(path))
26268        .then(function (r) { return r.json(); })
26269        .then(function (d) {
26270          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
26271        })
26272        .catch(function () {});
26273    }
26274
26275    function getDeltaFilteredRows() {
26276      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
26277        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
26278      });
26279    }
26280
26281    function renderDeltaPage() {
26282      var filtered = getDeltaFilteredRows();
26283      var total = filtered.length;
26284      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
26285      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
26286      var start = (deltaCurrPage - 1) * deltaPerPage;
26287      var end = Math.min(start + deltaPerPage, total);
26288      var shownSet = {};
26289      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
26290      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
26291        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
26292      });
26293      var rl = document.getElementById('pg-range-label');
26294      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total + ' files' : 'No results';
26295      var btns = document.getElementById('pg-btns');
26296      if (!btns) return;
26297      btns.innerHTML = '';
26298      if (totalPages <= 1) return;
26299      function makeBtn(lbl, pg, active, disabled) {
26300        var b = document.createElement('button');
26301        b.className = 'pg-btn' + (active ? ' active' : '');
26302        b.textContent = lbl; b.disabled = disabled;
26303        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
26304        return b;
26305      }
26306      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
26307      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26308      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
26309      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
26310    }
26311
26312    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
26313
26314    function filterRows(status, btn) {
26315      activeStatusFilter = status;
26316      deltaCurrPage = 1;
26317      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
26318        b.classList.remove('active');
26319      });
26320      if (btn) btn.classList.add('active');
26321      renderDeltaPage();
26322    }
26323
26324    // ── Sorting ──────────────────────────────────────────────────────────────
26325    var sortCol = null, sortOrder = 'asc';
26326    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
26327    (function() {
26328      var tbody = document.getElementById('delta-tbody');
26329      if (!tbody) return;
26330      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26331      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
26332    })();
26333
26334    function parseDeltaNum(str) {
26335      if (!str || str === '\u2014') return 0;
26336      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
26337    }
26338
26339    sortHeaders.forEach(function(th) {
26340      th.addEventListener('click', function(e) {
26341        if (e.target.classList.contains('col-resize-handle')) return;
26342        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26343        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26344        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26345        th.classList.add('sort-' + sortOrder);
26346        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
26347        var tbody = document.getElementById('delta-tbody');
26348        if (!tbody) return;
26349        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26350        rows.sort(function(a, b) {
26351          var va, vb;
26352          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
26353          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
26354          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
26355          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
26356          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26357          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26358          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26359          else { va = ''; vb = ''; }
26360          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26361          return va < vb ? 1 : va > vb ? -1 : 0;
26362        });
26363        rows.forEach(function(r) { tbody.appendChild(r); });
26364        deltaCurrPage = 1;
26365        renderDeltaPage();
26366        var activeBtn = document.querySelector('.tab-btn.active');
26367        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26368        if (activeBtn) activeBtn.classList.add('active');
26369      });
26370    });
26371
26372    // ── Column resize ─────────────────────────────────────────────────────────
26373    (function() {
26374      var table = document.getElementById('delta-table');
26375      if (!table) return;
26376      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26377      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
26378      ths.forEach(function(th, i) {
26379        var handle = th.querySelector('.col-resize-handle');
26380        if (!handle || !cols[i]) return;
26381        var startX, startW;
26382        handle.addEventListener('mousedown', function(e) {
26383          e.stopPropagation(); e.preventDefault();
26384          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26385          handle.classList.add('dragging');
26386          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26387          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26388          document.addEventListener('mousemove', onMove);
26389          document.addEventListener('mouseup', onUp);
26390        });
26391      });
26392    })();
26393
26394    // ── Reset ─────────────────────────────────────────────────────────────────
26395    window.resetDeltaTable = function() {
26396      sortCol = null; sortOrder = 'asc';
26397      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26398      var tbody = document.getElementById('delta-tbody');
26399      if (tbody) {
26400        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26401        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26402        rows.forEach(function(r) { tbody.appendChild(r); });
26403      }
26404      var table = document.getElementById('delta-table');
26405      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26406      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
26407      activeStatusFilter = 'all';
26408      deltaCurrPage = 1;
26409      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26410      var allBtn = document.querySelector('.tab-btn');
26411      if (allBtn) allBtn.classList.add('active');
26412      renderDeltaPage();
26413    };
26414
26415    renderDeltaPage();
26416
26417    // Compact number formatter (shared by the delta table; charts define their own locally)
26418    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();}
26419    function fmtFull(n){return Number(n).toLocaleString();}
26420
26421    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
26422    function fmtFromTo() {
26423      var tbody = document.getElementById('delta-tbody');
26424      if (!tbody) return;
26425      tbody.querySelectorAll('.delta-row').forEach(function(row) {
26426        var status = row.dataset.status || '';
26427        var ft = row.querySelector('.from-to');
26428        if (!ft) return;
26429        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
26430        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
26431        var strongs = ft.querySelectorAll('strong');
26432        // Apply fmt() to non-absent strong values
26433        strongs.forEach(function(el) {
26434          var n = parseInt(el.textContent, 10);
26435          if (!isNaN(n)) el.textContent = fmt(n);
26436        });
26437        // Safety: force dash for genuinely absent sides
26438        if (status === 'added' && bv === 0) {
26439          var bs = ft.querySelector('strong:first-of-type');
26440          if (bs && bs.textContent === '0') {
26441            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
26442          }
26443        }
26444        if (status === 'removed' && cv === 0) {
26445          var cs = ft.querySelector('strong:last-of-type');
26446          if (cs && cs.textContent === '0') {
26447            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
26448          }
26449        }
26450      });
26451    }
26452    fmtFromTo();
26453
26454    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
26455    (function() {
26456      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
26457        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
26458      });
26459      var resetBtn = document.getElementById('delta-reset-btn');
26460      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
26461      var csvBtn = document.getElementById('delta-csv-btn');
26462      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
26463      var xlsBtn = document.getElementById('delta-xls-btn');
26464      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
26465      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
26466      function sdFetchUri(path) {
26467        return fetch(path).then(function(r){return r.blob();}).then(function(b){
26468          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
26469        }).catch(function(){return '';});
26470      }
26471      function sdInlineImgs(html, cb) {
26472        var paths=[], seen={};
26473        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
26474        if(!paths.length){cb(html);return;}
26475        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
26476          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
26477          .catch(function(){cb(html);});
26478      }
26479      function buildFullPageHtml(pdfMode) {
26480        if(pdfMode) document.body.classList.add('pdf-mode');
26481        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
26482        renderDeltaPage();
26483        var html = document.documentElement.outerHTML;
26484        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
26485        if(pdfMode) document.body.classList.remove('pdf-mode');
26486        return html;
26487      }
26488      var chartsBtn = document.getElementById('delta-charts-btn');
26489      if (chartsBtn) chartsBtn.addEventListener('click', function() {
26490        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26491        sdInlineImgs(buildFullPageHtml(false), function(html) {
26492          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26493          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26494          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26495          btn.disabled=false;btn.innerHTML=orig;
26496        });
26497      });
26498      var pageHtmlBtn = document.getElementById('page-export-html-btn');
26499      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
26500        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26501        sdInlineImgs(buildFullPageHtml(false), function(html) {
26502          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26503          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26504          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26505          btn.disabled=false;btn.innerHTML=orig;
26506        });
26507      });
26508      // PDF export — clean document-style report, not a web page screenshot
26509      function buildDeltaPdfHtml() {
26510        var sd=_sd, dr=getDeltaExportRows();
26511        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
26512        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
26513        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
26514        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
26515        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26516        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();}
26517        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
26518        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>';}
26519        var lm={};
26520        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;});
26521        var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,15);
26522        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
26523        var css='body{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
26524          '.hdr{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}'+
26525          '.brand{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}'+
26526          '.title{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}'+
26527          '.proj{font-size:12px;color:#99aabb;margin-top:3px;}'+
26528          '.hr{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}'+
26529          '.body{padding:18px 24px;}'+
26530          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;}'+
26531          '.sc{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}'+
26532          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
26533          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
26534          '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:12px 16px;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
26535          '.meta>div{flex:1 1 0;}'+
26536          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:4px;font-size:15px;}'+
26537          '.sec{margin-bottom:18px;}'+
26538          '.sh{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}'+
26539          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
26540          'th{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;}'+
26541          'td{border-bottom:1px solid #eee;padding:5px 10px;vertical-align:middle;}'+
26542          'tr:nth-child(even) td{background:#faf8f6;}'+
26543          '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:16px;}';
26544        var fileRows=dr.slice(0,200).map(function(r){
26545          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
26546          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
26547            '<td style="'+ss+'">'+esc(st)+'</td>'+
26548            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
26549            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
26550            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
26551        }).join('');
26552        var more=dr.length>200?'<tr><td colspan="6" style="color:#888;font-style:italic;text-align:center">\u2026 '+fmtN(dr.length-200)+' more files \u2014 export to XLS for full list</td></tr>':'';
26553        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">'+delt(dv)+'</td></tr>';}).join('');
26554        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
26555          '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Scan Delta</div><div class="proj">'+esc(projName)+'</div></div>'+
26556          '<div class="hr">'+esc(_blabel)+'<br>'+esc(_clabel)+'<br>Generated: '+esc(now)+'</div></div>'+
26557          '<div class="body">'+
26558          '<div class="sg">'+
26559          '<div class="sc"><div class="sv">'+delt(sd.cd)+'</div><div class="sl">Code Lines \u0394</div></div>'+
26560          '<div class="sc"><div class="sv">'+delt(sd.fd)+'</div><div class="sl">Files \u0394</div></div>'+
26561          '<div class="sc"><div class="sv">'+delt(sd.cmd)+'</div><div class="sl">Comment Lines \u0394</div></div>'+
26562          '<div class="sc"><div class="sv" style="color:#111">'+fmtN(tfTotal)+'</div><div class="sl">Total Files</div></div>'+
26563          '</div>'+
26564          '<div class="meta">'+
26565          '<div><div class="ml">Baseline Code</div><div class="mv">'+fullN(sd.bc)+'</div></div>'+
26566          '<div><div class="ml">Current Code</div><div class="mv">'+fullN(sd.cc)+'</div></div>'+
26567          '<div><div class="ml">Modified</div><div class="mv">'+fullN(sd.fm)+'</div></div>'+
26568          '<div><div class="ml">Added</div><div class="mv" style="color:#2a6846">+'+fullN(sd.fa)+'</div></div>'+
26569          '<div><div class="ml">Removed</div><div class="mv" style="color:#b23030">-'+fullN(sd.fr)+'</div></div>'+
26570          '<div><div class="ml">Unchanged</div><div class="mv">'+fullN(sd.fu)+'</div></div>'+
26571          '</div>'+
26572          (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files Changed</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
26573          '<div class="sec"><p class="sh">File Delta ('+fmtN(dr.length)+' files)</p>'+
26574          '<table><thead><tr><th>File</th><th>Language</th><th>Status</th>'+
26575          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
26576          '</tr></thead><tbody>'+fileRows+more+'</tbody></table></div>'+
26577          '</div>'+
26578          '<div class="ftr"><span>oxide-sloc v{{ version }}</span><span>Scan Delta Report</span>'+
26579          '<span>'+esc(sd.bid)+' \u2192 '+esc(sd.cid)+'</span></div>'+
26580          '</body></html>';
26581      }
26582      function doDeltaPdf(btn) {
26583        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
26584        var html=buildDeltaPdfHtml();
26585        fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:html,filename:getExportFilename('pdf')})})
26586          .then(function(r){if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();})
26587          .then(function(blob){var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=getExportFilename('pdf');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);})
26588          .catch(function(e){alert('PDF export failed: '+e.message);})
26589          .finally(function(){btn.disabled=false;btn.innerHTML=orig;});
26590      }
26591      var pdfBtn = document.getElementById('delta-pdf-btn');
26592      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
26593      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
26594      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
26595      if (location.protocol === 'file:') {
26596        [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'; } });
26597        [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'; } });
26598      }
26599      var ppSel = document.getElementById('per-page-sel');
26600      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
26601      var pathLink = document.getElementById('project-path-link');
26602      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
26603    })();
26604
26605    // ── Export helpers ────────────────────────────────────────────────────────
26606    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
26607    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26608    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);}
26609    function slocMakeXlsx(fname,sd,dr){
26610      var enc=new TextEncoder();
26611      // CRC-32 table
26612      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;}
26613      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;}
26614      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26615      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26616      // Shared string table
26617      var ss=[],si={};
26618      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
26619      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26620      // Worksheet builder — each WS() call gets its own row counter R
26621      function WS(){
26622        var R=0,buf=[];
26623        function cl(c){return String.fromCharCode(65+c);}
26624        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
26625          '<v>'+S(v)+'</v></c>';}
26626        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
26627          (st?' s="'+st+'"':'')+'>'+
26628          '<v>'+(+v)+'</v></c>';}
26629        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
26630        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26631          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
26632          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
26633          '<sheetFormatPr defaultRowHeight="15"/>'+
26634          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
26635        return{sc:sc,nc:nc,row:row,xml:xml};
26636      }
26637      // Language breakdown
26638      var lm={};
26639      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;});
26640      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
26641      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
26642      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
26643      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
26644      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26645      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26646      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):'';}
26647      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
26648      // Summary sheet
26649      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
26650      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
26651      r1(s1(0,proj,2));
26652      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
26653      r1('');
26654      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
26655      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))));
26656      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))));
26657      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))));
26658      r1('');
26659      r1(s1(0,'FILE CHANGES',8));
26660      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
26661      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
26662      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
26663      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
26664      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
26665      if(langs.length){
26666        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
26667        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
26668        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)));});
26669      }
26670      r1('');r1(s1(0,'SCAN METADATA',8));
26671      r1(s1(1,_blabel)+s1(2,_clabel));
26672      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
26673      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
26674      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"/>');
26675      // File Delta sheet
26676      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
26677      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));
26678      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)));});
26679      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
26680      // Shared strings XML
26681      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26682        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
26683        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
26684      // XLSX file map
26685      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26686      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>',
26687        '_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>',
26688        '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>',
26689        '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>',
26690        '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>',
26691        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
26692      // ZIP packer — STORED (no compression), compatible with all XLSX readers
26693      var zparts=[],zcds=[],zoff=0,znf=0;
26694      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
26695       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
26696      ].forEach(function(name){
26697        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26698        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]);
26699        var entry=new Uint8Array(lha.length+nb.length+sz);
26700        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26701        zparts.push(entry);
26702        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));
26703        var cde=new Uint8Array(cda.length+nb.length);
26704        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26705        zcds.push(cde);zoff+=entry.length;znf++;
26706      });
26707      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26708      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]);
26709      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26710      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26711      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26712      zout.set(new Uint8Array(ea),zpos);
26713      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
26714      var xurl=URL.createObjectURL(xblob);
26715      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
26716      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
26717      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
26718    }
26719    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;');}
26720    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
26721    function getExportFilename(ext){return _exportBase+'.'+ext;}
26722
26723    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 %}};
26724    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;}
26725    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
26726    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
26727    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26728    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26729    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):'';}
26730    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
26731    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)]];}
26732    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
26733    function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){var b=parseInt(tr.getAttribute('data-baseline-code'))||0,c=parseInt(tr.getAttribute('data-current-code'))||0,st=tr.getAttribute('data-status')||'';r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',st,tr.getAttribute('data-baseline-code')||'',tr.getAttribute('data-current-code')||'',tr.getAttribute('data-code-delta')||'',tr.getAttribute('data-comment-delta')||'',tr.getAttribute('data-total-delta')||'',_filePct(b,c,st)]);});return r;}
26734    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
26735    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
26736
26737    // ── Chart HTML report ─────────────────────────────────────────────────────
26738    function slocChartReport(fname, sd, dr) {
26739      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
26740      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26741      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26742      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();}
26743      function px(n){return Math.round(n);}
26744      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
26745      // Language map
26746      var lm={};
26747      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;});
26748      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26749
26750      // Builds onmouse* attrs for interactive tooltip on each SVG element
26751      function barTT(label,val){
26752        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
26753      }
26754
26755      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
26756      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
26757      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26758      var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14;
26759      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26760      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26761      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"/>';}
26762      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26763      c1mets.forEach(function(m,i){
26764        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26765        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26766        c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
26767        c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
26768        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
26769        c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
26770        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
26771        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
26772        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
26773      });
26774      c1+='</svg>';
26775
26776      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
26777      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
26778      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26779      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
26780      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
26781      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26782      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26783      mets.forEach(function(m,i){
26784        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
26785        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
26786        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
26787        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
26788        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
26789        if(bw>=52){
26790          c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
26791        }else{
26792          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
26793          c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
26794        }
26795      });
26796      c2+='</svg>';
26797
26798      // ── Chart 3: Language Code Delta ─────────────────────────────────────
26799      var c3='';
26800      if(langs.length){
26801        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26802        var C3W=550,c3LW=124,c3FW=52;
26803        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
26804        var L3rH=30,C3H=langs.length*L3rH+20;
26805        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26806        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26807        langs.forEach(function(l,i){
26808          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
26809          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
26810          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26811          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26812          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 • '+e.f+' file'+(e.f!==1?'s':''))+'/>';
26813          if(bw>=48){
26814            c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
26815          }else{
26816            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
26817            c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
26818          }
26819          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
26820        });
26821        c3+='</svg>';
26822      }
26823
26824      // ── Chart 4: File Change Donut — centered pie with legend below
26825      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;});
26826      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26827      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26828      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">';
26829      var ang=-Math.PI/2;
26830      segs.forEach(function(s){
26831        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26832        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
26833        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
26834        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
26835        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
26836        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 • '+px(s.v/tot*100)+'%')+'/>';
26837        ang+=sw;
26838      });
26839      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
26840      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26841      segs.forEach(function(s,i){
26842        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26843        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
26844        c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
26845      });
26846      c4+='</svg>';
26847
26848      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
26849      var ttJs='var tt=document.getElementById("ox-tt");'+
26850        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
26851        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
26852        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
26853        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
26854        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
26855        'function oxHT(){tt.style.display="none";}';
26856
26857      // body max-width keeps charts from inflating beyond design dimensions on
26858      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
26859      // each chart's height blows up proportionally, breaking the one-page layout.
26860      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;}'+
26861        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
26862        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
26863        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
26864        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
26865        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
26866        'svg{display:block;}'+
26867        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
26868        '#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;}'+
26869        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
26870      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
26871        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26872        '<div id="ox-tt"><\/div>'+
26873        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
26874        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
26875        '<div class="two-col">'+
26876        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
26877        '<div class="leg">'+
26878        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26879        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26880        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
26881        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
26882        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
26883        '<\/div>'+
26884        '<div class="two-col">'+
26885        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
26886        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
26887        '<\/div>'+
26888        '<script>'+ttJs+'<\/script>'+
26889        '<\/body><\/html>';
26890      slocDownload(html, fname, 'text/html;charset=utf-8;');
26891    }
26892    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
26893    window.buildDeltaChartsHtml = function() {
26894      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26895      var sd=_sd;
26896      var projEl=document.querySelector('[data-folder]');
26897      var proj=projEl?projEl.getAttribute('data-folder'):'';
26898      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
26899      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
26900      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
26901      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
26902      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";}';
26903      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);}';
26904      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26905        '<div id="ox-tt"><\/div>'+
26906        '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26907        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' → '+esc(sd.cts||'')+'<\/p>'+
26908        '<div class="two-col">'+
26909        '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26910        '<div class="leg"><span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26911        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26912        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
26913        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
26914        '<\/div>'+
26915        '<div class="two-col">'+
26916        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
26917        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
26918        '<\/div>'+
26919        '<script>'+ttJs+'<\/script>'+
26920        '<\/body><\/html>';
26921    };
26922    // ── Inline delta charts ────────────────────────────────────────────────────
26923    var _icTT=document.getElementById('ic-tt');
26924    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
26925    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';};
26926    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
26927    window.addEventListener('blur',function(){window.icHT();});
26928    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
26929    (function(){
26930      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
26931      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26932      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();}
26933      function px(n){return Math.round(n);}
26934      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26935      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
26936      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);});}
26937      var dr=getDeltaExportRows(),sd=_sd,lm={};
26938      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;});
26939      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26940      // Chart 1: Baseline vs Current grouped bars
26941      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
26942      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26943      var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26944      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26945      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"/>';}
26946      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26947      c1mets.forEach(function(m,i){
26948        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26949        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26950        c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
26951        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"/>';
26952        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
26953        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"/>';
26954        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
26955        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
26956        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
26957      });
26958      c1+='</svg>';
26959      // Chart 2: Delta by Metric
26960      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
26961      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26962      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;
26963      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26964      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26965      mets.forEach(function(m,i){
26966        var y=16+i*rH,bw=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);
26967        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
26968        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
26969        if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
26970        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,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
26971      });
26972      c2+='</svg>';
26973      // Chart 3: Language Code Delta
26974      var c3='';
26975      if(langs.length){
26976        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26977        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;
26978        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26979        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26980        langs.forEach(function(l,i){
26981          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26982          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26983          c3+='<rect'+btt(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
26984          if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
26985          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,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
26986          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
26987        });
26988        c3+='</svg>';
26989      }
26990      // Chart 4: File Change Donut — centered pie with legend below
26991      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;});
26992      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26993      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26994      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">',ang=-Math.PI/2;
26995      if(segs.length===1){
26996        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
26997        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
26998      } else {
26999        segs.forEach(function(s){
27000          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
27001          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);
27002          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);
27003          c4+='<path'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*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="white" stroke-width="2.5"/>';
27004          ang+=sw;
27005        });
27006      }
27007      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt(tot)+'</text>';
27008      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
27009      segs.forEach(function(s,i){
27010        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
27011        c4+='<rect'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
27012        c4+='<text'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt(s.v)+'</text>';
27013      });
27014      c4+='</svg>';
27015      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
27016      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
27017      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);}
27018      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
27019      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
27020
27021      // Compare Timeline chart (Baseline vs Current, 2 points)
27022      (function() {
27023        var activeCmpMetric='code';
27024        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
27025        function renderCmpTL(metric) {
27026          var svg=document.getElementById('cmp-tl-svg');if(!svg)return;
27027          var W=svg.getBoundingClientRect().width||800,H=280;
27028          svg.setAttribute('height',H);
27029          var pad={l:62,r:20,t:32,b:72};
27030          var dark=document.body.classList.contains('dark-theme');
27031          var cmpPts=[
27032            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
27033            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
27034          ];
27035          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
27036          var valid=pts.filter(function(v){return v!=null;});
27037          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;}
27038          var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
27039          if(minV===maxV){minV=Math.max(0,minV-1);maxV=maxV+1;}
27040          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
27041          var cx0=pad.l,cx1=pad.l+plotW;
27042          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27043          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27044          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
27045          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
27046          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
27047          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();}
27048          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27049          var parts=[];
27050          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
27051          for(var gi=0;gi<5;gi++){
27052            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
27053            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
27054            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
27055          }
27056          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+'"/>');
27057          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"/>');
27058          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
27059                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
27060          dotPts.forEach(function(pt){
27061            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+'">'+fmtN(pt.v)+'</text>');
27062            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"/>');
27063            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>');
27064            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>');
27065          });
27066          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
27067          svg.setAttribute('viewBox','0 0 '+W+' '+H);
27068          svg.innerHTML=parts.join('');
27069          // Hover: crosshair + tooltip (matches multi-scan timeline)
27070          var cmpTT=document.getElementById('ic-tt');
27071          svg.onmousemove=function(e){
27072            var rect=svg.getBoundingClientRect();
27073            var scaleX=W/rect.width;
27074            var mouseX=(e.clientX-rect.left)*scaleX;
27075            var nearest=-1,minDist=Infinity;
27076            var cxArr=[cx0,cx1];
27077            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;}}
27078            if(nearest<0)return;
27079            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
27080            var xhair=svg.querySelector('.cmp-xhair');
27081            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
27082            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"/>';
27083            if(!cmpTT)return;
27084            var clbl=cmpPts[nearest].label;
27085            var scanLbl=nearest===0?'Baseline':'Current';
27086            cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+fmtN(pts[nearest])+'</strong>';
27087            var bx=rect.left+(nc/W*rect.width)+18;
27088            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
27089            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
27090          };
27091          svg.onmouseleave=function(){
27092            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
27093            if(cmpTT)cmpTT.style.display='none';
27094          };
27095        }
27096        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
27097          btn.addEventListener('click',function(){
27098            activeCmpMetric=this.dataset.cmpMetric;
27099            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
27100            this.classList.add('active');
27101            renderCmpTL(activeCmpMetric);
27102          });
27103        });
27104        var ttgl=document.getElementById('theme-toggle');
27105        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);},0);});
27106        if(typeof ResizeObserver!=='undefined'){
27107          var cmpSvg=document.getElementById('cmp-tl-svg');
27108          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
27109        }
27110        renderCmpTL(activeCmpMetric);
27111      })();
27112
27113      // HTML legend hover -> highlight matching SVG bars within the SAME card only
27114      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
27115        var metric=leg.getAttribute('data-highlight');
27116        var parentCard=leg.closest('.ic-card');
27117        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
27118        if(!chartEl)return;
27119        leg.addEventListener('mouseenter',function(){
27120          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
27121            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';}
27122            else{x.style.opacity='0.28';}
27123          });
27124        });
27125        leg.addEventListener('mouseleave',function(){
27126          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
27127        });
27128      });
27129      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
27130    })();
27131  </script>
27132  <script nonce="{{ csp_nonce }}">
27133  (function(){
27134    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'}];
27135    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);});}
27136    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27137    function init(){
27138      var btn=document.getElementById('settings-btn');if(!btn)return;
27139      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27140      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>';
27141      document.body.appendChild(m);
27142      var g=document.getElementById('scheme-grid');
27143      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);});
27144      var cl=document.getElementById('settings-close');
27145      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);
27146      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');});
27147      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27148      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27149    }
27150    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27151  }());
27152  </script>
27153  <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]';
27154  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;}
27155  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>
27156</body>
27157</html>
27158"##,
27159    ext = "html"
27160)]
27161// Template structs need many bool fields to pass Askama rendering flags.
27162#[allow(clippy::struct_excessive_bools)]
27163struct CompareTemplate {
27164    version: &'static str,
27165    project_label: String,
27166    baseline_git_commit: String,
27167    current_git_commit: String,
27168    baseline_run_id: String,
27169    current_run_id: String,
27170    baseline_run_id_short: String,
27171    current_run_id_short: String,
27172    baseline_timestamp: String,
27173    baseline_timestamp_utc_ms: i64,
27174    current_timestamp: String,
27175    current_timestamp_utc_ms: i64,
27176    project_path: String,
27177    baseline_code: u64,
27178    current_code: u64,
27179    code_lines_delta_str: String,
27180    code_lines_delta_class: String,
27181    baseline_files: u64,
27182    current_files: u64,
27183    files_analyzed_delta_str: String,
27184    files_analyzed_delta_class: String,
27185    baseline_comments: u64,
27186    current_comments: u64,
27187    comment_lines_delta_str: String,
27188    comment_lines_delta_class: String,
27189    baseline_code_fmt: String,
27190    current_code_fmt: String,
27191    baseline_files_fmt: String,
27192    current_files_fmt: String,
27193    baseline_comments_fmt: String,
27194    current_comments_fmt: String,
27195    code_lines_pct_str: String,
27196    files_analyzed_pct_str: String,
27197    comment_lines_pct_str: String,
27198    code_lines_added: i64,
27199    code_lines_removed: i64,
27200    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
27201    new_scope: bool,
27202    churn_rate_str: String,
27203    churn_rate_class: String,
27204    scope_flag: bool,
27205    files_added: usize,
27206    files_removed: usize,
27207    files_modified: usize,
27208    files_unchanged: usize,
27209    file_rows: Vec<CompareFileDeltaRow>,
27210    baseline_git_author: Option<String>,
27211    current_git_author: Option<String>,
27212    baseline_git_branch: String,
27213    current_git_branch: String,
27214    baseline_git_tags: Option<String>,
27215    current_git_tags: Option<String>,
27216    baseline_git_commit_date: Option<String>,
27217    current_git_commit_date: Option<String>,
27218    project_name: String,
27219    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
27220    submodule_options: Vec<String>,
27221    /// True when either run has submodule data — controls whether the scope bar is shown.
27222    has_any_submodule_data: bool,
27223    /// The submodule currently being compared, if the `sub` query param was provided.
27224    active_submodule: Option<String>,
27225    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
27226    super_scope_active: bool,
27227    csp_nonce: String,
27228    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
27229    coverage_delta_card: String,
27230    baseline_test_count: u64,
27231    current_test_count: u64,
27232    baseline_coverage_pct: Option<f64>,
27233    current_coverage_pct: Option<f64>,
27234}
27235
27236// ── LoginTemplate ──────────────────────────────────────────────────────────────
27237
27238#[derive(Template)]
27239#[template(
27240    source = r##"
27241<!doctype html>
27242<html lang="en">
27243<head>
27244  <meta charset="utf-8">
27245  <meta name="viewport" content="width=device-width, initial-scale=1">
27246  <title>OxideSLOC | Sign In</title>
27247  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27248  <style nonce="{{ csp_nonce }}">
27249    :root {
27250      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
27251      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
27252      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
27253      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
27254    }
27255    *{box-sizing:border-box;}
27256    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);}
27257    .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);}
27258    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
27259    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
27260    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
27261    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27262    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27263    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27264    .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;}
27265    @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));}}
27266    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
27267    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
27268    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27269    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
27270    .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;}
27271    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
27272    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;}
27273    input[type=password]:focus{border-color:var(--oxide);}
27274    .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;}
27275    .btn:hover{opacity:.88;}
27276    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
27277    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
27278  </style>
27279</head>
27280<body>
27281  <div class="background-watermarks" aria-hidden="true">
27282    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27287    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27288    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27289  </div>
27290  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27291<nav class="top-nav">
27292  <a class="brand" href="/">
27293    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
27294    <span class="brand-title">OxideSLOC</span>
27295  </a>
27296</nav>
27297<main class="page">
27298  <div class="card">
27299    <h1>Sign In</h1>
27300    <p class="subtitle">Enter the API key printed when the server started.</p>
27301    {% if has_error %}
27302    <div class="error">Incorrect API key — please try again.</div>
27303    {% endif %}
27304    <form method="POST" action="/auth/login">
27305      <input type="hidden" name="next" value="{{ next_url|e }}">
27306      <label for="key">API Key</label>
27307      <input id="key" type="password" name="key" autocomplete="current-password"
27308             placeholder="Paste your API key here" autofocus>
27309      <button type="submit" class="btn">Sign In</button>
27310    </form>
27311    <p class="hint">
27312      The API key was printed in the terminal when the server started.<br>
27313      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
27314      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
27315    </p>
27316  </div>
27317</main>
27318<script nonce="{{ csp_nonce }}">
27319(function() {
27320  (function randomizeWatermarks() {
27321    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27322    if (!wms.length) return;
27323    var placed = [];
27324    function tooClose(top, left) {
27325      for (var i = 0; i < placed.length; i++) {
27326        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
27327        if (dt < 16 && dl < 12) return true;
27328      }
27329      return false;
27330    }
27331    function pick(leftBand) {
27332      for (var attempt = 0; attempt < 50; attempt++) {
27333        var top = Math.random() * 88 + 2;
27334        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27335        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
27336      }
27337      var top = Math.random() * 88 + 2;
27338      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27339      placed.push([top, left]); return [top, left];
27340    }
27341    var half = Math.floor(wms.length / 2);
27342    wms.forEach(function (img, i) {
27343      var pos = pick(i < half);
27344      var size = Math.floor(Math.random() * 100 + 120);
27345      var rot = (Math.random() * 360).toFixed(1);
27346      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
27347      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;
27348    });
27349  })();
27350  (function spawnCodeParticles() {
27351    var container = document.getElementById('code-particles');
27352    if (!container) return;
27353    var snippets = [
27354      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
27355      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
27356      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
27357      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
27358      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
27359    ];
27360    var count = 38;
27361    for (var i = 0; i < count; i++) {
27362      (function(idx) {
27363        var el = document.createElement('span');
27364        el.className = 'code-particle';
27365        el.textContent = snippets[idx % snippets.length];
27366        var left = Math.random() * 94 + 2;
27367        var top = Math.random() * 88 + 6;
27368        var dur = (Math.random() * 10 + 9).toFixed(1);
27369        var delay = (Math.random() * 18).toFixed(1);
27370        var rot = (Math.random() * 26 - 13).toFixed(1);
27371        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27372        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
27373        container.appendChild(el);
27374      })(i);
27375    }
27376  })();
27377})();
27378</script>
27379</body>
27380</html>
27381"##,
27382    ext = "html"
27383)]
27384pub(crate) struct LoginTemplate {
27385    pub(crate) csp_nonce: String,
27386    pub(crate) has_error: bool,
27387    pub(crate) next_url: String,
27388    pub(crate) lockout_threshold: u32,
27389}
27390
27391// ── REST API reference page ────────────────────────────────────────────────────
27392
27393#[derive(Template)]
27394#[template(
27395    source = r##"
27396<!doctype html>
27397<html lang="en">
27398<head>
27399  <meta charset="utf-8">
27400  <meta name="viewport" content="width=device-width, initial-scale=1">
27401  <title>OxideSLOC — REST API Reference</title>
27402  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27403  <style nonce="{{ csp_nonce }}">
27404    :root {
27405      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27406      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27407      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27408      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27409      --success:#16a34a;
27410    }
27411    body.dark-theme {
27412      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
27413      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
27414    }
27415    *{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;}
27416    .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);}
27417    .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;}
27418    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
27419    .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));}
27420    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
27421    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
27422    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
27423    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
27424    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27425    @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; } }
27426    .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;}
27427    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
27428    .nav-pill.active{background:rgba(255,255,255,0.22);}
27429    .nav-dropdown{position:relative;display:inline-flex;}
27430    .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;}
27431    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
27432    .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;}
27433    .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;}
27434    .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);}
27435    .nav-dropdown-menu a:last-child{border-bottom:none;}
27436    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
27437    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
27438    .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;}
27439    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27440    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27441    .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;}
27442    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27443    .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);}
27444    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
27445    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
27446    .settings-modal-body{padding:14px 16px 16px;}
27447    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27448    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27449    .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;}
27450    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27451    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27452    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27453    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27454    .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;}
27455    .tz-select:focus{border-color:var(--oxide);}
27456    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
27457    .page-header{margin-bottom:28px;}
27458    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
27459    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
27460    .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;}
27461    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
27462    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
27463    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
27464    .callout strong{font-weight:800;}
27465    .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;}
27466    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
27467    .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;}
27468    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
27469    .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;}
27470    body.dark-theme .base-url-value{color:var(--accent);}
27471    .section{margin-bottom:36px;}
27472    .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);}
27473    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
27474    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
27475    .ep-header:hover{background:var(--surface-2);}
27476    .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;}
27477    .method.get{background:#dcfce7;color:#166534;}
27478    .method.post{background:#dbeafe;color:#1e40af;}
27479    .method.delete{background:#fee2e2;color:#991b1b;}
27480    body.dark-theme .method.get{background:#14532d;color:#86efac;}
27481    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
27482    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
27483    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
27484    .ep-path .param{color:var(--oxide-2);}
27485    body.dark-theme .ep-path .param{color:var(--oxide);}
27486    .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;}
27487    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
27488    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
27489    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
27490    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
27491    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
27492    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
27493    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
27494    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
27495    .ep-card.open .chevron{transform:rotate(180deg);}
27496    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
27497    .ep-card.open .ep-body{display:block;}
27498    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
27499    .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;}
27500    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
27501    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
27502    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27503    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
27504    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);}
27505    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
27506    table.params tr:last-child td{border-bottom:none;}
27507    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
27508    .pt-type{color:var(--muted-2);font-size:12px;}
27509    .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;}
27510    .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;}
27511    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
27512    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
27513    details.schema{margin-bottom:14px;}
27514    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;}
27515    details.schema summary:hover{color:var(--text);}
27516    .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;}
27517    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27518    .curl-wrap{position:relative;}
27519    .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;}
27520    .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;}
27521    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
27522    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
27523    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
27524    .webhook-note a{color:var(--accent-2);text-decoration:none;}
27525    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27526    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27527    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27528    .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;}
27529    @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));}}
27530    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27531    .site-footer a{color:var(--muted);}
27532  </style>
27533</head>
27534<body>
27535  <div class="background-watermarks" aria-hidden="true">
27536    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27537    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27538    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27539    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27540    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27541    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27542    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27543  </div>
27544  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27545  <div class="top-nav">
27546    <div class="top-nav-inner">
27547      <a class="brand" href="/">
27548        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27549        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
27550      </a>
27551      <div class="nav-right">
27552        <a class="nav-pill" href="/">Home</a>
27553        <div class="nav-dropdown">
27554          <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>
27555          <div class="nav-dropdown-menu">
27556            <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>
27557          </div>
27558        </div>
27559        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27560        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27561        <div class="nav-dropdown">
27562          <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>
27563          <div class="nav-dropdown-menu">
27564            <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>
27565          </div>
27566        </div>
27567        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27568          <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>
27569        </button>
27570        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27571          <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>
27572          <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>
27573        </button>
27574      </div>
27575    </div>
27576  </div>
27577
27578  <div class="page">
27579    <div class="page-header">
27580      <h1 class="page-title">REST API Reference</h1>
27581      <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>
27582    </div>
27583
27584    {% if has_api_key %}
27585    <div class="callout key-set">
27586      <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>
27587      <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>
27588    </div>
27589    {% else %}
27590    <div class="callout no-key">
27591      <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>
27592      <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>
27593    </div>
27594    {% endif %}
27595
27596    <div class="base-url-bar">
27597      <span class="base-url-label">Base URL</span>
27598      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
27599    </div>
27600
27601    <!-- Health -->
27602    <div class="section">
27603      <h2 class="section-title">Health &amp; Status</h2>
27604      <div class="ep-card">
27605        <div class="ep-header">
27606          <span class="method get">GET</span>
27607          <span class="ep-path">/healthz</span>
27608          <span class="auth-badge public">Public</span>
27609          <span class="ep-desc">Server liveness check</span>
27610          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27611        </div>
27612        <div class="ep-body">
27613          <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>
27614          <p class="params-heading">Response</p>
27615          <div class="schema-block">200 OK
27616Content-Type: text/plain
27617
27618ok</div>
27619          <p class="curl-heading">Example</p>
27620          <div class="curl-wrap">
27621            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
27622            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
27623          </div>
27624        </div>
27625      </div>
27626    </div>
27627
27628    <!-- Badges -->
27629    <div class="section">
27630      <h2 class="section-title">Badges</h2>
27631      <div class="ep-card">
27632        <div class="ep-header">
27633          <span class="method get">GET</span>
27634          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
27635          <span class="auth-badge public">Public</span>
27636          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
27637          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27638        </div>
27639        <div class="ep-body">
27640          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
27641          <p class="params-heading">Path Parameters</p>
27642          <table class="params">
27643            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27644            <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>
27645          </table>
27646          <p class="curl-heading">Example</p>
27647          <div class="curl-wrap">
27648            <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>
27649            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
27650          </div>
27651        </div>
27652      </div>
27653    </div>
27654
27655    <!-- Metrics -->
27656    <div class="section">
27657      <h2 class="section-title">Metrics</h2>
27658
27659      <div class="ep-card">
27660        <div class="ep-header">
27661          <span class="method get">GET</span>
27662          <span class="ep-path">/api/metrics/latest</span>
27663          <span class="auth-badge protected">Protected</span>
27664          <span class="ep-desc">Latest scan metrics (JSON)</span>
27665          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27666        </div>
27667        <div class="ep-body">
27668          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
27669          <details class="schema"><summary>Response schema</summary>
27670<div class="schema-block">{
27671  "run_id":    string,        // UUID
27672  "timestamp": string,        // ISO-8601 UTC
27673  "project":   string,        // scanned root path
27674  "summary": {
27675    "files_analyzed":       number,
27676    "files_skipped":        number,
27677    "code_lines":           number,
27678    "comment_lines":        number,
27679    "blank_lines":          number,
27680    "total_physical_lines": number,
27681    "functions":            number,
27682    "classes":              number,
27683    "variables":            number,
27684    "imports":              number
27685  },
27686  "languages": [
27687    { "name": string, "files": number, "code_lines": number,
27688      "comment_lines": number, "blank_lines": number,
27689      "functions": number, "classes": number,
27690      "variables": number, "imports": number }
27691  ]
27692}</div></details>
27693          <p class="curl-heading">Example</p>
27694          <div class="curl-wrap">
27695            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27696  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
27697            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
27698          </div>
27699        </div>
27700      </div>
27701
27702      <div class="ep-card">
27703        <div class="ep-header">
27704          <span class="method get">GET</span>
27705          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
27706          <span class="auth-badge protected">Protected</span>
27707          <span class="ep-desc">Metrics for a specific run</span>
27708          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27709        </div>
27710        <div class="ep-body">
27711          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
27712          <p class="params-heading">Path Parameters</p>
27713          <table class="params">
27714            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27715            <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>
27716          </table>
27717          <p class="curl-heading">Example</p>
27718          <div class="curl-wrap">
27719            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27720  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
27721            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
27722          </div>
27723        </div>
27724      </div>
27725
27726      <div class="ep-card">
27727        <div class="ep-header">
27728          <span class="method get">GET</span>
27729          <span class="ep-path">/api/metrics/history</span>
27730          <span class="auth-badge protected">Protected</span>
27731          <span class="ep-desc">Paginated scan history</span>
27732          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27733        </div>
27734        <div class="ep-body">
27735          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
27736          <p class="params-heading">Query Parameters</p>
27737          <table class="params">
27738            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27739            <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>
27740            <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>
27741          </table>
27742          <details class="schema"><summary>Response schema</summary>
27743<div class="schema-block">[{
27744  "run_id":         string,
27745  "timestamp":      string,   // ISO-8601 UTC
27746  "commit":         string | null,
27747  "branch":         string | null,
27748  "tags":           string[],
27749  "code_lines":     number,
27750  "comment_lines":  number,
27751  "blank_lines":    number,
27752  "physical_lines": number,
27753  "files_analyzed": number,
27754  "project_label":  string,
27755  "html_url":       string | null
27756}]</div></details>
27757          <p class="curl-heading">Example</p>
27758          <div class="curl-wrap">
27759            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27760  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
27761            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
27762          </div>
27763        </div>
27764      </div>
27765
27766      <div class="ep-card">
27767        <div class="ep-header">
27768          <span class="method get">GET</span>
27769          <span class="ep-path">/api/project-history</span>
27770          <span class="auth-badge protected">Protected</span>
27771          <span class="ep-desc">Project-level scan summary</span>
27772          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27773        </div>
27774        <div class="ep-body">
27775          <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>
27776          <p class="params-heading">Query Parameters</p>
27777          <table class="params">
27778            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27779            <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>
27780          </table>
27781          <details class="schema"><summary>Response schema</summary>
27782<div class="schema-block">{
27783  "scan_count":           number,
27784  "last_scan_id":         string | null,
27785  "last_scan_timestamp":  string | null,  // ISO-8601
27786  "last_scan_code_lines": number | null,
27787  "last_git_branch":      string | null,
27788  "last_git_commit":      string | null
27789}</div></details>
27790          <p class="curl-heading">Example</p>
27791          <div class="curl-wrap">
27792            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27793  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
27794            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
27795          </div>
27796        </div>
27797      </div>
27798
27799      <div class="ep-card">
27800        <div class="ep-header">
27801          <span class="method get">GET</span>
27802          <span class="ep-path">/api/metrics/submodules</span>
27803          <span class="auth-badge protected">Protected</span>
27804          <span class="ep-desc">List known git submodules across scans</span>
27805          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27806        </div>
27807        <div class="ep-body">
27808          <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>
27809          <p class="params-heading">Query Parameters</p>
27810          <table class="params">
27811            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27812            <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>
27813          </table>
27814          <details class="schema"><summary>Response schema</summary>
27815<div class="schema-block">[{
27816  "name":          string,  // submodule name
27817  "relative_path": string   // path relative to the project root
27818}]</div></details>
27819          <p class="curl-heading">Example</p>
27820          <div class="curl-wrap">
27821            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27822  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
27823            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
27824          </div>
27825        </div>
27826      </div>
27827    </div>
27828
27829    <!-- Async Run Status -->
27830    <div class="section">
27831      <h2 class="section-title">Async Run Status</h2>
27832
27833      <div class="ep-card">
27834        <div class="ep-header">
27835          <span class="method get">GET</span>
27836          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
27837          <span class="auth-badge protected">Protected</span>
27838          <span class="ep-desc">Poll scan completion</span>
27839          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27840        </div>
27841        <div class="ep-body">
27842          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
27843          <details class="schema"><summary>Response schema</summary>
27844<div class="schema-block">// Running
27845{ "state": "running",  "elapsed_secs": number }
27846
27847// Complete
27848{ "state": "complete", "run_id": string }
27849
27850// Failed
27851{ "state": "failed",   "message": string }</div></details>
27852          <p class="curl-heading">Example</p>
27853          <div class="curl-wrap">
27854            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27855  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
27856            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
27857          </div>
27858        </div>
27859      </div>
27860
27861      <div class="ep-card">
27862        <div class="ep-header">
27863          <span class="method get">GET</span>
27864          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
27865          <span class="auth-badge protected">Protected</span>
27866          <span class="ep-desc">Poll PDF generation readiness</span>
27867          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27868        </div>
27869        <div class="ep-body">
27870          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
27871          <details class="schema"><summary>Response schema</summary>
27872<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
27873          <p class="curl-heading">Example</p>
27874          <div class="curl-wrap">
27875            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27876  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
27877            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
27878          </div>
27879        </div>
27880      </div>
27881
27882      <div class="ep-card">
27883        <div class="ep-header">
27884          <span class="method post">POST</span>
27885          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
27886          <span class="auth-badge protected">Protected</span>
27887          <span class="ep-desc">Cancel a running scan</span>
27888          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27889        </div>
27890        <div class="ep-body">
27891          <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>
27892          <p class="curl-heading">Example</p>
27893          <div class="curl-wrap">
27894            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
27895  -H "Authorization: Bearer $SLOC_API_KEY" \
27896  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
27897            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
27898          </div>
27899        </div>
27900      </div>
27901    </div>
27902
27903    <!-- Run Management -->
27904    <div class="section">
27905      <h2 class="section-title">Run Management</h2>
27906
27907      <div class="ep-card">
27908        <div class="ep-header">
27909          <span class="method get">GET</span>
27910          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
27911          <span class="auth-badge protected">Protected</span>
27912          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
27913          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27914        </div>
27915        <div class="ep-body">
27916          <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>
27917          <p class="params-heading">Path Parameters</p>
27918          <table class="params">
27919            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27920            <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>
27921          </table>
27922          <details class="schema"><summary>Response</summary>
27923<div class="schema-block">200 OK — Content-Type: application/zip
27924Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
27925
27926404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
27927          <p class="curl-heading">Example</p>
27928          <div class="curl-wrap">
27929            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27930  -o run.zip \
27931  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
27932            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
27933          </div>
27934        </div>
27935      </div>
27936
27937      <div class="ep-card">
27938        <div class="ep-header">
27939          <span class="method delete">DELETE</span>
27940          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
27941          <span class="auth-badge protected">Protected</span>
27942          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
27943          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27944        </div>
27945        <div class="ep-body">
27946          <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>
27947          <p class="params-heading">Path Parameters</p>
27948          <table class="params">
27949            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27950            <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>
27951          </table>
27952          <details class="schema"><summary>Response</summary>
27953<div class="schema-block">204 No Content — run successfully deleted
27954
27955500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
27956          <p class="curl-heading">Example</p>
27957          <div class="curl-wrap">
27958            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
27959  -H "Authorization: Bearer $SLOC_API_KEY" \
27960  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
27961            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
27962          </div>
27963        </div>
27964      </div>
27965
27966      <div class="ep-card">
27967        <div class="ep-header">
27968          <span class="method post">POST</span>
27969          <span class="ep-path">/api/runs/cleanup</span>
27970          <span class="auth-badge protected">Protected</span>
27971          <span class="ep-desc">Bulk delete runs older than N days</span>
27972          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27973        </div>
27974        <div class="ep-body">
27975          <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>
27976          <p class="params-heading">Request Body (application/json)</p>
27977          <table class="params">
27978            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
27979            <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>
27980          </table>
27981          <details class="schema"><summary>Response schema</summary>
27982<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
27983          <p class="curl-heading">Example — delete runs older than 60 days</p>
27984          <div class="curl-wrap">
27985            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
27986  -H "Authorization: Bearer $SLOC_API_KEY" \
27987  -H "Content-Type: application/json" \
27988  -d '{"older_than_days":60}' \
27989  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
27990            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
27991          </div>
27992        </div>
27993      </div>
27994    </div>
27995
27996    <!-- Retention Policy -->
27997    <div class="section">
27998      <h2 class="section-title">Retention Policy</h2>
27999
28000      <div class="ep-card">
28001        <div class="ep-header">
28002          <span class="method get">GET</span>
28003          <span class="ep-path">/api/cleanup-policy</span>
28004          <span class="auth-badge protected">Protected</span>
28005          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
28006          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28007        </div>
28008        <div class="ep-body">
28009          <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>
28010          <details class="schema"><summary>Response schema</summary>
28011<div class="schema-block">{
28012  "policy": {
28013    "enabled":       boolean,
28014    "max_age_days":  number | null,   // delete runs older than N days
28015    "max_run_count": number | null,   // keep only the N most recent runs
28016    "interval_hours": number          // hours between background passes
28017  } | null,
28018  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
28019  "last_run_deleted": number | null   // runs deleted in last pass
28020}</div></details>
28021          <p class="curl-heading">Example</p>
28022          <div class="curl-wrap">
28023            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28024  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28025            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
28026          </div>
28027        </div>
28028      </div>
28029
28030      <div class="ep-card">
28031        <div class="ep-header">
28032          <span class="method post">POST</span>
28033          <span class="ep-path">/api/cleanup-policy</span>
28034          <span class="auth-badge protected">Protected</span>
28035          <span class="ep-desc">Save or update the retention policy</span>
28036          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28037        </div>
28038        <div class="ep-body">
28039          <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>
28040          <p class="params-heading">Request Body (application/json)</p>
28041          <table class="params">
28042            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28043            <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>
28044            <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>
28045            <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>
28046            <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>
28047          </table>
28048          <details class="schema"><summary>Response</summary>
28049<div class="schema-block">204 No Content — policy saved and task (re)started
28050
28051500 Internal Server Error — { "error": string }</div></details>
28052          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
28053          <div class="curl-wrap">
28054            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
28055  -H "Authorization: Bearer $SLOC_API_KEY" \
28056  -H "Content-Type: application/json" \
28057  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
28058  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28059            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
28060          </div>
28061        </div>
28062      </div>
28063
28064      <div class="ep-card">
28065        <div class="ep-header">
28066          <span class="method post">POST</span>
28067          <span class="ep-path">/api/cleanup-policy/run-now</span>
28068          <span class="auth-badge protected">Protected</span>
28069          <span class="ep-desc">Trigger an immediate cleanup pass</span>
28070          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28071        </div>
28072        <div class="ep-body">
28073          <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>
28074          <details class="schema"><summary>Response schema</summary>
28075<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
28076          <p class="curl-heading">Example</p>
28077          <div class="curl-wrap">
28078            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
28079  -H "Authorization: Bearer $SLOC_API_KEY" \
28080  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
28081            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
28082          </div>
28083        </div>
28084      </div>
28085
28086      <div class="ep-card">
28087        <div class="ep-header">
28088          <span class="method delete">DELETE</span>
28089          <span class="ep-path">/api/cleanup-policy</span>
28090          <span class="auth-badge protected">Protected</span>
28091          <span class="ep-desc">Remove the retention policy and stop the background task</span>
28092          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28093        </div>
28094        <div class="ep-body">
28095          <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>
28096          <details class="schema"><summary>Response</summary>
28097<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
28098          <p class="curl-heading">Example</p>
28099          <div class="curl-wrap">
28100            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
28101  -H "Authorization: Bearer $SLOC_API_KEY" \
28102  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28103            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
28104          </div>
28105        </div>
28106      </div>
28107    </div>
28108
28109    <!-- Scan Profiles -->
28110    <div class="section">
28111      <h2 class="section-title">Scan Profiles</h2>
28112
28113      <div class="ep-card">
28114        <div class="ep-header">
28115          <span class="method get">GET</span>
28116          <span class="ep-path">/api/scan-profiles</span>
28117          <span class="auth-badge protected">Protected</span>
28118          <span class="ep-desc">List saved scan profiles</span>
28119          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28120        </div>
28121        <div class="ep-body">
28122          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
28123          <details class="schema"><summary>Response schema</summary>
28124<div class="schema-block">{
28125  "profiles": [{
28126    "id":         string,   // UUID
28127    "name":       string,
28128    "created_at": string,   // ISO-8601
28129    "params":     object
28130  }]
28131}</div></details>
28132          <p class="curl-heading">Example</p>
28133          <div class="curl-wrap">
28134            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28135  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28136            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
28137          </div>
28138        </div>
28139      </div>
28140
28141      <div class="ep-card">
28142        <div class="ep-header">
28143          <span class="method post">POST</span>
28144          <span class="ep-path">/api/scan-profiles</span>
28145          <span class="auth-badge protected">Protected</span>
28146          <span class="ep-desc">Save a scan profile</span>
28147          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28148        </div>
28149        <div class="ep-body">
28150          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
28151          <p class="params-heading">Request Body (application/json)</p>
28152          <table class="params">
28153            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28154            <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>
28155            <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>
28156          </table>
28157          <details class="schema"><summary>Response schema</summary>
28158<div class="schema-block">{ "ok": true }</div></details>
28159          <p class="curl-heading">Example</p>
28160          <div class="curl-wrap">
28161            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
28162  -H "Authorization: Bearer $SLOC_API_KEY" \
28163  -H "Content-Type: application/json" \
28164  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
28165  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28166            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
28167          </div>
28168        </div>
28169      </div>
28170
28171      <div class="ep-card">
28172        <div class="ep-header">
28173          <span class="method delete">DELETE</span>
28174          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
28175          <span class="auth-badge protected">Protected</span>
28176          <span class="ep-desc">Delete a scan profile</span>
28177          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28178        </div>
28179        <div class="ep-body">
28180          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
28181          <p class="params-heading">Path Parameters</p>
28182          <table class="params">
28183            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28184            <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>
28185          </table>
28186          <details class="schema"><summary>Response schema</summary>
28187<div class="schema-block">{ "ok": true }</div></details>
28188          <p class="curl-heading">Example</p>
28189          <div class="curl-wrap">
28190            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
28191  -H "Authorization: Bearer $SLOC_API_KEY" \
28192  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
28193            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
28194          </div>
28195        </div>
28196      </div>
28197    </div>
28198
28199    <!-- Scheduled Scans -->
28200    <div class="section">
28201      <h2 class="section-title">Scheduled Scans</h2>
28202
28203      <div class="ep-card">
28204        <div class="ep-header">
28205          <span class="method get">GET</span>
28206          <span class="ep-path">/api/schedules</span>
28207          <span class="auth-badge protected">Protected</span>
28208          <span class="ep-desc">List configured schedules</span>
28209          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28210        </div>
28211        <div class="ep-body">
28212          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
28213          <p class="curl-heading">Example</p>
28214          <div class="curl-wrap">
28215            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28216  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28217            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
28218          </div>
28219        </div>
28220      </div>
28221
28222      <div class="ep-card">
28223        <div class="ep-header">
28224          <span class="method post">POST</span>
28225          <span class="ep-path">/api/schedules</span>
28226          <span class="auth-badge protected">Protected</span>
28227          <span class="ep-desc">Create a schedule</span>
28228          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28229        </div>
28230        <div class="ep-body">
28231          <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>
28232          <p class="curl-heading">Example</p>
28233          <div class="curl-wrap">
28234            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
28235  -H "Authorization: Bearer $SLOC_API_KEY" \
28236  -H "Content-Type: application/json" \
28237  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
28238  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28239            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
28240          </div>
28241        </div>
28242      </div>
28243
28244      <div class="ep-card">
28245        <div class="ep-header">
28246          <span class="method delete">DELETE</span>
28247          <span class="ep-path">/api/schedules</span>
28248          <span class="auth-badge protected">Protected</span>
28249          <span class="ep-desc">Delete a schedule</span>
28250          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28251        </div>
28252        <div class="ep-body">
28253          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
28254          <p class="curl-heading">Example</p>
28255          <div class="curl-wrap">
28256            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
28257  -H "Authorization: Bearer $SLOC_API_KEY" \
28258  -H "Content-Type: application/json" \
28259  -d '{"id":"&lt;schedule_id&gt;"}' \
28260  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28261            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
28262          </div>
28263        </div>
28264      </div>
28265    </div>
28266
28267    <!-- Git Browser -->
28268    <div class="section">
28269      <h2 class="section-title">Git Browser</h2>
28270
28271      <div class="ep-card">
28272        <div class="ep-header">
28273          <span class="method get">GET</span>
28274          <span class="ep-path">/api/git/refs</span>
28275          <span class="auth-badge protected">Protected</span>
28276          <span class="ep-desc">List git refs for a repository</span>
28277          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28278        </div>
28279        <div class="ep-body">
28280          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
28281          <p class="params-heading">Query Parameters</p>
28282          <table class="params">
28283            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28284            <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>
28285          </table>
28286          <p class="curl-heading">Example</p>
28287          <div class="curl-wrap">
28288            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28289  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
28290            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
28291          </div>
28292        </div>
28293      </div>
28294
28295      <div class="ep-card">
28296        <div class="ep-header">
28297          <span class="method get">GET</span>
28298          <span class="ep-path">/api/git/scan-ref</span>
28299          <span class="auth-badge protected">Protected</span>
28300          <span class="ep-desc">SLOC-scan a specific git ref</span>
28301          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28302        </div>
28303        <div class="ep-body">
28304          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
28305          <p class="params-heading">Query Parameters</p>
28306          <table class="params">
28307            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28308            <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>
28309            <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>
28310          </table>
28311          <p class="curl-heading">Example</p>
28312          <div class="curl-wrap">
28313            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28314  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
28315            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
28316          </div>
28317        </div>
28318      </div>
28319
28320      <div class="ep-card">
28321        <div class="ep-header">
28322          <span class="method get">GET</span>
28323          <span class="ep-path">/api/git/compare-refs</span>
28324          <span class="auth-badge protected">Protected</span>
28325          <span class="ep-desc">Compare SLOC across two git refs</span>
28326          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28327        </div>
28328        <div class="ep-body">
28329          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
28330          <p class="params-heading">Query Parameters</p>
28331          <table class="params">
28332            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28333            <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>
28334            <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>
28335            <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>
28336          </table>
28337          <p class="curl-heading">Example</p>
28338          <div class="curl-wrap">
28339            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28340  "<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>
28341            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
28342          </div>
28343        </div>
28344      </div>
28345    </div>
28346
28347    <!-- Webhooks -->
28348    <div class="section">
28349      <h2 class="section-title">Webhooks</h2>
28350      <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>
28351
28352      <div class="ep-card">
28353        <div class="ep-header">
28354          <span class="method post">POST</span>
28355          <span class="ep-path">/webhooks/github</span>
28356          <span class="auth-badge hmac">HMAC</span>
28357          <span class="ep-desc">GitHub push event receiver</span>
28358          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28359        </div>
28360        <div class="ep-body">
28361          <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>
28362          <p class="params-heading">Required Headers</p>
28363          <table class="params">
28364            <tr><th>Header</th><th>Value</th></tr>
28365            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
28366            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
28367            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28368          </table>
28369        </div>
28370      </div>
28371
28372      <div class="ep-card">
28373        <div class="ep-header">
28374          <span class="method post">POST</span>
28375          <span class="ep-path">/webhooks/gitlab</span>
28376          <span class="auth-badge hmac">HMAC</span>
28377          <span class="ep-desc">GitLab push event receiver</span>
28378          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28379        </div>
28380        <div class="ep-body">
28381          <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>
28382          <p class="params-heading">Required Headers</p>
28383          <table class="params">
28384            <tr><th>Header</th><th>Value</th></tr>
28385            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
28386            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
28387            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28388          </table>
28389        </div>
28390      </div>
28391
28392      <div class="ep-card">
28393        <div class="ep-header">
28394          <span class="method post">POST</span>
28395          <span class="ep-path">/webhooks/bitbucket</span>
28396          <span class="auth-badge hmac">HMAC</span>
28397          <span class="ep-desc">Bitbucket push event receiver</span>
28398          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28399        </div>
28400        <div class="ep-body">
28401          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
28402          <p class="params-heading">Required Headers</p>
28403          <table class="params">
28404            <tr><th>Header</th><th>Value</th></tr>
28405            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
28406            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28407          </table>
28408        </div>
28409      </div>
28410    </div>
28411
28412    <!-- Config -->
28413    <div class="section">
28414      <h2 class="section-title">Config Import / Export</h2>
28415
28416      <div class="ep-card">
28417        <div class="ep-header">
28418          <span class="method get">GET</span>
28419          <span class="ep-path">/export-config</span>
28420          <span class="auth-badge protected">Protected</span>
28421          <span class="ep-desc">Export server configuration as JSON</span>
28422          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28423        </div>
28424        <div class="ep-body">
28425          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
28426          <p class="curl-heading">Example</p>
28427          <div class="curl-wrap">
28428            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28429  -o config.json \
28430  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
28431            <button class="curl-copy-btn" data-target="c-export">Copy</button>
28432          </div>
28433        </div>
28434      </div>
28435
28436      <div class="ep-card">
28437        <div class="ep-header">
28438          <span class="method post">POST</span>
28439          <span class="ep-path">/import-config</span>
28440          <span class="auth-badge protected">Protected</span>
28441          <span class="ep-desc">Import server configuration</span>
28442          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28443        </div>
28444        <div class="ep-body">
28445          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
28446          <p class="curl-heading">Example</p>
28447          <div class="curl-wrap">
28448            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
28449  -H "Authorization: Bearer $SLOC_API_KEY" \
28450  -H "Content-Type: application/json" \
28451  -d @config.json \
28452  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
28453            <button class="curl-copy-btn" data-target="c-import">Copy</button>
28454          </div>
28455        </div>
28456      </div>
28457    </div>
28458
28459    <!-- CI Ingest -->
28460    <div class="section">
28461      <h2 class="section-title">CI Ingest</h2>
28462
28463      <div class="ep-card">
28464        <div class="ep-header">
28465          <span class="method post">POST</span>
28466          <span class="ep-path">/api/ingest</span>
28467          <span class="auth-badge protected">Protected</span>
28468          <span class="ep-desc">Push a pre-computed scan result from CI</span>
28469          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28470        </div>
28471        <div class="ep-body">
28472          <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>
28473          <p class="params-heading">Query Parameters</p>
28474          <table class="params">
28475            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28476            <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>
28477          </table>
28478          <p class="params-heading">Request Body (application/json)</p>
28479          <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>
28480          <details class="schema"><summary>Response schema</summary>
28481<div class="schema-block">// 201 Created
28482{
28483  "run_id":   string,  // UUID of the ingested run
28484  "view_url": string   // relative URL to the report page
28485}</div></details>
28486          <p class="curl-heading">Example</p>
28487          <div class="curl-wrap">
28488            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
28489  -H "Authorization: Bearer $SLOC_API_KEY" \
28490  -H "Content-Type: application/json" \
28491  -d @result.json \
28492  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
28493            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
28494          </div>
28495        </div>
28496      </div>
28497    </div>
28498
28499    <!-- Artifact Download -->
28500    <div class="section">
28501      <h2 class="section-title">Artifact Download</h2>
28502
28503      <div class="ep-card">
28504        <div class="ep-header">
28505          <span class="method get">GET</span>
28506          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
28507          <span class="auth-badge protected">Protected</span>
28508          <span class="ep-desc">Download or view a scan artifact</span>
28509          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28510        </div>
28511        <div class="ep-body">
28512          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
28513          <p class="params-heading">Path Parameters</p>
28514          <table class="params">
28515            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28516            <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>
28517            <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>
28518          </table>
28519          <p class="params-heading">Query Parameters</p>
28520          <table class="params">
28521            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28522            <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>
28523          </table>
28524          <p class="curl-heading">Example — download JSON result</p>
28525          <div class="curl-wrap">
28526            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28527  -o result.json \
28528  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
28529            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
28530          </div>
28531        </div>
28532      </div>
28533    </div>
28534
28535    <!-- Embed Widget -->
28536    <div class="section">
28537      <h2 class="section-title">Embed Widget</h2>
28538
28539      <div class="ep-card">
28540        <div class="ep-header">
28541          <span class="method get">GET</span>
28542          <span class="ep-path">/embed/summary</span>
28543          <span class="auth-badge protected">Protected</span>
28544          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
28545          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28546        </div>
28547        <div class="ep-body">
28548          <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>
28549          <p class="params-heading">Query Parameters</p>
28550          <table class="params">
28551            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28552            <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>
28553            <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>
28554          </table>
28555          <p class="curl-heading">Example</p>
28556          <div class="curl-wrap">
28557            <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"
28558        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
28559            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
28560          </div>
28561        </div>
28562      </div>
28563    </div>
28564
28565    <!-- Confluence Integration -->
28566    <div class="section">
28567      <h2 class="section-title">Confluence Integration</h2>
28568
28569      <div class="ep-card">
28570        <div class="ep-header">
28571          <span class="method get">GET</span>
28572          <span class="ep-path">/api/confluence/config</span>
28573          <span class="auth-badge protected">Protected</span>
28574          <span class="ep-desc">Get current Confluence configuration</span>
28575          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28576        </div>
28577        <div class="ep-body">
28578          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
28579          <details class="schema"><summary>Response schema</summary>
28580<div class="schema-block">{
28581  "configured":     boolean,
28582  "tier":           "cloud" | "server",
28583  "base_url":       string,
28584  "username":       string,
28585  "api_token_set":  boolean,
28586  "space_key":      string,
28587  "parent_page_id": string | null,
28588  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
28589}</div></details>
28590          <p class="curl-heading">Example</p>
28591          <div class="curl-wrap">
28592            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28593  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28594            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
28595          </div>
28596        </div>
28597      </div>
28598
28599      <div class="ep-card">
28600        <div class="ep-header">
28601          <span class="method post">POST</span>
28602          <span class="ep-path">/api/confluence/config</span>
28603          <span class="auth-badge protected">Protected</span>
28604          <span class="ep-desc">Save Confluence configuration</span>
28605          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28606        </div>
28607        <div class="ep-body">
28608          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
28609          <p class="params-heading">Request Body (application/json)</p>
28610          <table class="params">
28611            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28612            <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>
28613            <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>
28614            <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>
28615            <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>
28616            <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>
28617            <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>
28618            <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>
28619          </table>
28620          <details class="schema"><summary>Response schema</summary>
28621<div class="schema-block">{ "ok": true }</div></details>
28622          <p class="curl-heading">Example</p>
28623          <div class="curl-wrap">
28624            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
28625  -H "Authorization: Bearer $SLOC_API_KEY" \
28626  -H "Content-Type: application/json" \
28627  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
28628  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28629            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
28630          </div>
28631        </div>
28632      </div>
28633
28634      <div class="ep-card">
28635        <div class="ep-header">
28636          <span class="method post">POST</span>
28637          <span class="ep-path">/api/confluence/test</span>
28638          <span class="auth-badge protected">Protected</span>
28639          <span class="ep-desc">Test Confluence connection</span>
28640          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28641        </div>
28642        <div class="ep-body">
28643          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
28644          <details class="schema"><summary>Response schema</summary>
28645<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
28646          <p class="curl-heading">Example</p>
28647          <div class="curl-wrap">
28648            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
28649  -H "Authorization: Bearer $SLOC_API_KEY" \
28650  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
28651            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
28652          </div>
28653        </div>
28654      </div>
28655
28656      <div class="ep-card">
28657        <div class="ep-header">
28658          <span class="method post">POST</span>
28659          <span class="ep-path">/api/confluence/post</span>
28660          <span class="auth-badge protected">Protected</span>
28661          <span class="ep-desc">Publish a scan report to Confluence</span>
28662          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28663        </div>
28664        <div class="ep-body">
28665          <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>
28666          <p class="params-heading">Request Body (application/json)</p>
28667          <table class="params">
28668            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28669            <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>
28670            <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>
28671            <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>
28672          </table>
28673          <details class="schema"><summary>Response schema</summary>
28674<div class="schema-block">// 200 OK
28675{ "ok": true, "page_id": string }
28676
28677// 400 / 502 on error
28678{ "ok": false, "error": string }</div></details>
28679          <p class="curl-heading">Example</p>
28680          <div class="curl-wrap">
28681            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
28682  -H "Authorization: Bearer $SLOC_API_KEY" \
28683  -H "Content-Type: application/json" \
28684  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
28685  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
28686            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
28687          </div>
28688        </div>
28689      </div>
28690
28691      <div class="ep-card">
28692        <div class="ep-header">
28693          <span class="method get">GET</span>
28694          <span class="ep-path">/api/confluence/wiki-markup</span>
28695          <span class="auth-badge protected">Protected</span>
28696          <span class="ep-desc">Get Confluence wiki markup for a run</span>
28697          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28698        </div>
28699        <div class="ep-body">
28700          <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>
28701          <p class="params-heading">Query Parameters</p>
28702          <table class="params">
28703            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28704            <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>
28705          </table>
28706          <p class="curl-heading">Example</p>
28707          <div class="curl-wrap">
28708            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28709  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
28710            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
28711          </div>
28712        </div>
28713      </div>
28714    </div>
28715
28716    <!-- Authentication -->
28717    <div class="section">
28718      <h2 class="section-title">Authentication</h2>
28719      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
28720
28721      <div class="ep-card">
28722        <div class="ep-header">
28723          <span class="method get">GET</span>
28724          <span class="ep-path">/auth/login</span>
28725          <span class="auth-badge public">Public</span>
28726          <span class="ep-desc">Login page</span>
28727          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28728        </div>
28729        <div class="ep-body">
28730          <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>
28731          <p class="params-heading">Query Parameters</p>
28732          <table class="params">
28733            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28734            <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>
28735            <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>
28736          </table>
28737        </div>
28738      </div>
28739
28740      <div class="ep-card">
28741        <div class="ep-header">
28742          <span class="method post">POST</span>
28743          <span class="ep-path">/auth/login</span>
28744          <span class="auth-badge public">Public</span>
28745          <span class="ep-desc">Submit credentials and get a session cookie</span>
28746          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28747        </div>
28748        <div class="ep-body">
28749          <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>
28750          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
28751          <table class="params">
28752            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28753            <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>
28754            <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>
28755          </table>
28756          <p class="curl-heading">Example</p>
28757          <div class="curl-wrap">
28758            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
28759  -d "key=$SLOC_API_KEY&amp;next=/" \
28760  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
28761            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
28762          </div>
28763        </div>
28764      </div>
28765    </div>
28766
28767    <!-- Coverage Suggestion -->
28768    <div class="section">
28769      <h2 class="section-title">Coverage Suggestion</h2>
28770
28771      <div class="ep-card">
28772        <div class="ep-header">
28773          <span class="method get">GET</span>
28774          <span class="ep-path">/api/suggest-coverage</span>
28775          <span class="auth-badge protected">Protected</span>
28776          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
28777          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28778        </div>
28779        <div class="ep-body">
28780          <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML) and returns the first one found, along with a hint for how to generate it if not present.</p>
28781          <p class="params-heading">Query Parameters</p>
28782          <table class="params">
28783            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28784            <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>
28785          </table>
28786          <details class="schema"><summary>Response schema</summary>
28787<div class="schema-block">{
28788  "found": string | null,  // absolute path to the coverage file, if detected
28789  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
28790  "hint":  string | null   // shell command to generate coverage if not found
28791}</div></details>
28792          <p class="curl-heading">Example</p>
28793          <div class="curl-wrap">
28794            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28795  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
28796            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
28797          </div>
28798        </div>
28799      </div>
28800    </div>
28801
28802  </div>
28803
28804  <footer class="site-footer">
28805    local code analysis - metrics, history and reports
28806    &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>
28807    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28808    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28809    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28810    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28811  </footer>
28812
28813  <script nonce="{{ csp_nonce }}">
28814    (function () {
28815      var base = window.location.origin;
28816      document.getElementById('base-url').textContent = base;
28817      document.querySelectorAll('.base-url-slot').forEach(function (el) {
28818        el.textContent = base;
28819      });
28820
28821      document.querySelectorAll('.ep-header').forEach(function (hdr) {
28822        hdr.addEventListener('click', function () {
28823          hdr.closest('.ep-card').classList.toggle('open');
28824        });
28825      });
28826
28827      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
28828        btn.addEventListener('click', function () {
28829          var targetId = btn.dataset.target;
28830          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
28831          if (!pre) return;
28832          navigator.clipboard.writeText(pre.textContent).then(function () {
28833            btn.textContent = 'Copied!';
28834            btn.classList.add('copied');
28835            setTimeout(function () {
28836              btn.textContent = 'Copy';
28837              btn.classList.remove('copied');
28838            }, 2000);
28839          });
28840        });
28841      });
28842
28843      var storageKey = 'oxide-sloc-theme';
28844      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
28845      var themeBtn = document.getElementById('theme-toggle');
28846      if (themeBtn) {
28847        themeBtn.addEventListener('click', function () {
28848          var dark = document.body.classList.toggle('dark-theme');
28849          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
28850        });
28851      }
28852      (function() {
28853        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'}];
28854        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);});}
28855        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28856        var btn=document.getElementById('settings-btn');if(!btn)return;
28857        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28858        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>';
28859        document.body.appendChild(m);
28860        var g=document.getElementById('scheme-grid');
28861        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);});
28862        var cl=document.getElementById('settings-close');
28863        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);
28864        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');});
28865        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28866        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28867      })();
28868      (function randomizeWatermarks() {
28869        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28870        if (!wms.length) return;
28871        var placed = [];
28872        function tooClose(top, left) {
28873          for (var i = 0; i < placed.length; i++) {
28874            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
28875            if (dt < 16 && dl < 12) return true;
28876          }
28877          return false;
28878        }
28879        function pick(leftBand) {
28880          for (var attempt = 0; attempt < 50; attempt++) {
28881            var top = Math.random() * 88 + 2;
28882            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28883            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28884          }
28885          var top = Math.random() * 88 + 2;
28886          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28887          placed.push([top, left]); return [top, left];
28888        }
28889        var half = Math.floor(wms.length / 2);
28890        wms.forEach(function (img, i) {
28891          var pos = pick(i < half);
28892          var size = Math.floor(Math.random() * 100 + 120);
28893          var rot = (Math.random() * 360).toFixed(1);
28894          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
28895          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;
28896        });
28897      })();
28898      (function spawnCodeParticles() {
28899        var container = document.getElementById('code-particles');
28900        if (!container) return;
28901        var snippets = [
28902          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
28903          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
28904          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
28905          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
28906          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
28907        ];
28908        var count = 38;
28909        for (var i = 0; i < count; i++) {
28910          (function(idx) {
28911            var el = document.createElement('span');
28912            el.className = 'code-particle';
28913            el.textContent = snippets[idx % snippets.length];
28914            var left = Math.random() * 94 + 2;
28915            var top = Math.random() * 88 + 6;
28916            var dur = (Math.random() * 10 + 9).toFixed(1);
28917            var delay = (Math.random() * 18).toFixed(1);
28918            var rot = (Math.random() * 26 - 13).toFixed(1);
28919            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28920            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
28921            container.appendChild(el);
28922          })(i);
28923        }
28924      })();
28925    }());
28926  </script>
28927</body>
28928</html>
28929"##,
28930    ext = "html"
28931)]
28932struct ApiDocsTemplate {
28933    has_api_key: bool,
28934    csp_nonce: String,
28935    version: &'static str,
28936}
28937
28938#[cfg(test)]
28939mod form_config_tests {
28940    use super::*;
28941    use sloc_config::{
28942        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
28943    };
28944
28945    fn blank_form() -> AnalyzeForm {
28946        AnalyzeForm {
28947            path: ".".to_string(),
28948            git_repo: None,
28949            git_ref: None,
28950            mixed_line_policy: None,
28951            python_docstrings_as_comments: None,
28952            generated_file_detection: None,
28953            minified_file_detection: None,
28954            vendor_directory_detection: None,
28955            include_lockfiles: None,
28956            binary_file_behavior: None,
28957            output_dir: None,
28958            report_title: None,
28959            report_header_footer: None,
28960            include_globs: None,
28961            exclude_globs: None,
28962            submodule_breakdown: None,
28963            coverage_file: None,
28964            continuation_line_policy: None,
28965            blank_in_block_comment_policy: None,
28966            count_compiler_directives: None,
28967            style_col_threshold: None,
28968            style_analysis_enabled: None,
28969            style_score_threshold: None,
28970            style_lang_scope: None,
28971            cocomo_mode: None,
28972            complexity_alert: None,
28973            exclude_duplicates: None,
28974        }
28975    }
28976
28977    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
28978        let mut cfg = sloc_config::AppConfig::default();
28979        apply_form_to_config(&mut cfg, form);
28980        cfg
28981    }
28982
28983    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
28984
28985    #[test]
28986    fn python_docstrings_false_when_unchecked() {
28987        // Checkbox absent in form data (unchecked) → field must be false.
28988        let cfg = apply(&blank_form());
28989        assert!(
28990            !cfg.analysis.python_docstrings_as_comments,
28991            "absent python_docstrings_as_comments must map to false"
28992        );
28993    }
28994
28995    #[test]
28996    fn python_docstrings_true_when_checked() {
28997        // Browser sends "on" (no value= attr on the checkbox).
28998        let mut form = blank_form();
28999        form.python_docstrings_as_comments = Some("on".to_string());
29000        let cfg = apply(&form);
29001        assert!(cfg.analysis.python_docstrings_as_comments);
29002    }
29003
29004    #[test]
29005    fn python_docstrings_true_for_any_non_none_value() {
29006        // The handler uses .is_some() — any non-None value means "checked".
29007        let mut form = blank_form();
29008        form.python_docstrings_as_comments = Some("true".to_string());
29009        assert!(apply(&form).analysis.python_docstrings_as_comments);
29010    }
29011
29012    // ── submodule_breakdown (checkbox with value="enabled") ──
29013
29014    #[test]
29015    fn submodule_breakdown_false_when_unchecked() {
29016        let cfg = apply(&blank_form());
29017        assert!(
29018            !cfg.discovery.submodule_breakdown,
29019            "absent submodule_breakdown must map to false"
29020        );
29021    }
29022
29023    #[test]
29024    fn submodule_breakdown_true_when_value_enabled() {
29025        let mut form = blank_form();
29026        form.submodule_breakdown = Some("enabled".to_string());
29027        assert!(apply(&form).discovery.submodule_breakdown);
29028    }
29029
29030    #[test]
29031    fn submodule_breakdown_false_for_wrong_value() {
29032        // If somehow a value other than "enabled" is sent, it must still be false.
29033        let mut form = blank_form();
29034        form.submodule_breakdown = Some("on".to_string());
29035        assert!(
29036            !apply(&form).discovery.submodule_breakdown,
29037            "submodule_breakdown only becomes true for the exact value 'enabled'"
29038        );
29039    }
29040
29041    // ── generated_file_detection (select: "enabled" | "disabled") ──
29042
29043    #[test]
29044    fn generated_detection_true_when_enabled() {
29045        let mut form = blank_form();
29046        form.generated_file_detection = Some("enabled".to_string());
29047        assert!(apply(&form).analysis.generated_file_detection);
29048    }
29049
29050    #[test]
29051    fn generated_detection_false_when_disabled() {
29052        let mut form = blank_form();
29053        form.generated_file_detection = Some("disabled".to_string());
29054        assert!(!apply(&form).analysis.generated_file_detection);
29055    }
29056
29057    #[test]
29058    fn generated_detection_true_when_absent() {
29059        // None != Some("disabled") → true (safe default)
29060        assert!(
29061            apply(&blank_form()).analysis.generated_file_detection,
29062            "absent field must default to true (detection on)"
29063        );
29064    }
29065
29066    // ── minified_file_detection ──
29067
29068    #[test]
29069    fn minified_detection_false_when_disabled() {
29070        let mut form = blank_form();
29071        form.minified_file_detection = Some("disabled".to_string());
29072        assert!(!apply(&form).analysis.minified_file_detection);
29073    }
29074
29075    #[test]
29076    fn minified_detection_true_when_enabled() {
29077        let mut form = blank_form();
29078        form.minified_file_detection = Some("enabled".to_string());
29079        assert!(apply(&form).analysis.minified_file_detection);
29080    }
29081
29082    #[test]
29083    fn minified_detection_true_when_absent() {
29084        assert!(apply(&blank_form()).analysis.minified_file_detection);
29085    }
29086
29087    // ── vendor_directory_detection ──
29088
29089    #[test]
29090    fn vendor_detection_false_when_disabled() {
29091        let mut form = blank_form();
29092        form.vendor_directory_detection = Some("disabled".to_string());
29093        assert!(!apply(&form).analysis.vendor_directory_detection);
29094    }
29095
29096    #[test]
29097    fn vendor_detection_true_when_enabled() {
29098        let mut form = blank_form();
29099        form.vendor_directory_detection = Some("enabled".to_string());
29100        assert!(apply(&form).analysis.vendor_directory_detection);
29101    }
29102
29103    #[test]
29104    fn vendor_detection_true_when_absent() {
29105        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
29106    }
29107
29108    // ── include_lockfiles (select: "disabled" default | "enabled") ──
29109
29110    #[test]
29111    fn lockfiles_false_when_absent() {
29112        // None == Some("enabled") is false → lockfiles off (correct safe default)
29113        assert!(!apply(&blank_form()).analysis.include_lockfiles);
29114    }
29115
29116    #[test]
29117    fn lockfiles_false_when_disabled() {
29118        let mut form = blank_form();
29119        form.include_lockfiles = Some("disabled".to_string());
29120        assert!(!apply(&form).analysis.include_lockfiles);
29121    }
29122
29123    #[test]
29124    fn lockfiles_true_when_enabled() {
29125        let mut form = blank_form();
29126        form.include_lockfiles = Some("enabled".to_string());
29127        assert!(apply(&form).analysis.include_lockfiles);
29128    }
29129
29130    // ── count_compiler_directives ──
29131
29132    #[test]
29133    fn compiler_directives_true_when_absent() {
29134        assert!(
29135            apply(&blank_form()).analysis.count_compiler_directives,
29136            "absent count_compiler_directives must default to true"
29137        );
29138    }
29139
29140    #[test]
29141    fn compiler_directives_true_when_enabled() {
29142        let mut form = blank_form();
29143        form.count_compiler_directives = Some("enabled".to_string());
29144        assert!(apply(&form).analysis.count_compiler_directives);
29145    }
29146
29147    #[test]
29148    fn compiler_directives_false_when_disabled() {
29149        let mut form = blank_form();
29150        form.count_compiler_directives = Some("disabled".to_string());
29151        assert!(!apply(&form).analysis.count_compiler_directives);
29152    }
29153
29154    // ── mixed_line_policy (enum select) ──
29155
29156    #[test]
29157    fn mixed_policy_unchanged_when_absent() {
29158        // None → if-let does nothing → stays at config default (CodeOnly)
29159        assert_eq!(
29160            apply(&blank_form()).analysis.mixed_line_policy,
29161            MixedLinePolicy::CodeOnly
29162        );
29163    }
29164
29165    #[test]
29166    fn mixed_policy_code_only() {
29167        let mut form = blank_form();
29168        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
29169        assert_eq!(
29170            apply(&form).analysis.mixed_line_policy,
29171            MixedLinePolicy::CodeOnly
29172        );
29173    }
29174
29175    #[test]
29176    fn mixed_policy_code_and_comment() {
29177        let mut form = blank_form();
29178        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
29179        assert_eq!(
29180            apply(&form).analysis.mixed_line_policy,
29181            MixedLinePolicy::CodeAndComment
29182        );
29183    }
29184
29185    #[test]
29186    fn mixed_policy_comment_only() {
29187        let mut form = blank_form();
29188        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
29189        assert_eq!(
29190            apply(&form).analysis.mixed_line_policy,
29191            MixedLinePolicy::CommentOnly
29192        );
29193    }
29194
29195    #[test]
29196    fn mixed_policy_separate_mixed_category() {
29197        let mut form = blank_form();
29198        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
29199        assert_eq!(
29200            apply(&form).analysis.mixed_line_policy,
29201            MixedLinePolicy::SeparateMixedCategory
29202        );
29203    }
29204
29205    // ── binary_file_behavior (enum select) ──
29206
29207    #[test]
29208    fn binary_behavior_skip_when_absent() {
29209        assert_eq!(
29210            apply(&blank_form()).analysis.binary_file_behavior,
29211            BinaryFileBehavior::Skip
29212        );
29213    }
29214
29215    #[test]
29216    fn binary_behavior_skip() {
29217        let mut form = blank_form();
29218        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
29219        assert_eq!(
29220            apply(&form).analysis.binary_file_behavior,
29221            BinaryFileBehavior::Skip
29222        );
29223    }
29224
29225    #[test]
29226    fn binary_behavior_fail() {
29227        let mut form = blank_form();
29228        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
29229        assert_eq!(
29230            apply(&form).analysis.binary_file_behavior,
29231            BinaryFileBehavior::Fail
29232        );
29233    }
29234
29235    // ── continuation_line_policy (enum select) ──
29236
29237    #[test]
29238    fn continuation_policy_each_physical_when_absent() {
29239        assert_eq!(
29240            apply(&blank_form()).analysis.continuation_line_policy,
29241            ContinuationLinePolicy::EachPhysicalLine
29242        );
29243    }
29244
29245    #[test]
29246    fn continuation_policy_collapse_to_logical() {
29247        let mut form = blank_form();
29248        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
29249        assert_eq!(
29250            apply(&form).analysis.continuation_line_policy,
29251            ContinuationLinePolicy::CollapseToLogical
29252        );
29253    }
29254
29255    // ── blank_in_block_comment_policy (enum select) ──
29256
29257    #[test]
29258    fn blank_in_block_comment_count_as_comment_when_absent() {
29259        assert_eq!(
29260            apply(&blank_form()).analysis.blank_in_block_comment_policy,
29261            BlankInBlockCommentPolicy::CountAsComment
29262        );
29263    }
29264
29265    #[test]
29266    fn blank_in_block_comment_count_as_blank() {
29267        let mut form = blank_form();
29268        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
29269        assert_eq!(
29270            apply(&form).analysis.blank_in_block_comment_policy,
29271            BlankInBlockCommentPolicy::CountAsBlank
29272        );
29273    }
29274
29275    // ── style_col_threshold ──
29276
29277    #[test]
29278    fn style_threshold_80() {
29279        let mut form = blank_form();
29280        form.style_col_threshold = Some("80".to_string());
29281        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
29282    }
29283
29284    #[test]
29285    fn style_threshold_100() {
29286        let mut form = blank_form();
29287        form.style_col_threshold = Some("100".to_string());
29288        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
29289    }
29290
29291    #[test]
29292    fn style_threshold_120() {
29293        let mut form = blank_form();
29294        form.style_col_threshold = Some("120".to_string());
29295        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
29296    }
29297
29298    #[test]
29299    fn style_threshold_invalid_value_leaves_default() {
29300        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
29301        let mut cfg = sloc_config::AppConfig::default();
29302        let mut form = blank_form();
29303        form.style_col_threshold = Some("42".to_string());
29304        apply_form_to_config(&mut cfg, &form);
29305        assert_eq!(
29306            cfg.analysis.style_col_threshold, 80,
29307            "invalid threshold must not change config"
29308        );
29309    }
29310
29311    #[test]
29312    fn style_threshold_non_numeric_leaves_default() {
29313        let mut cfg = sloc_config::AppConfig::default();
29314        let mut form = blank_form();
29315        form.style_col_threshold = Some("large".to_string());
29316        apply_form_to_config(&mut cfg, &form);
29317        assert_eq!(cfg.analysis.style_col_threshold, 80);
29318    }
29319
29320    #[test]
29321    fn style_threshold_zero_leaves_default() {
29322        let mut cfg = sloc_config::AppConfig::default();
29323        let mut form = blank_form();
29324        form.style_col_threshold = Some("0".to_string());
29325        apply_form_to_config(&mut cfg, &form);
29326        assert_eq!(cfg.analysis.style_col_threshold, 80);
29327    }
29328
29329    #[test]
29330    fn style_threshold_absent_leaves_default() {
29331        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
29332    }
29333
29334    // ── coverage_file ──
29335
29336    #[test]
29337    fn coverage_file_none_when_absent() {
29338        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
29339    }
29340
29341    #[test]
29342    fn coverage_file_none_when_whitespace_only() {
29343        let mut form = blank_form();
29344        form.coverage_file = Some("   ".to_string());
29345        assert!(
29346            apply(&form).analysis.coverage_file.is_none(),
29347            "whitespace-only coverage_file must be treated as None"
29348        );
29349    }
29350
29351    #[test]
29352    fn coverage_file_set_when_non_empty() {
29353        let mut form = blank_form();
29354        form.coverage_file = Some("coverage/lcov.info".to_string());
29355        assert_eq!(
29356            apply(&form).analysis.coverage_file,
29357            Some(std::path::PathBuf::from("coverage/lcov.info"))
29358        );
29359    }
29360
29361    #[test]
29362    fn coverage_file_trims_whitespace() {
29363        let mut form = blank_form();
29364        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
29365        assert_eq!(
29366            apply(&form).analysis.coverage_file,
29367            Some(std::path::PathBuf::from("coverage/lcov.info"))
29368        );
29369    }
29370
29371    // ── report_title ──
29372
29373    #[test]
29374    fn report_title_unchanged_when_absent() {
29375        let original = sloc_config::AppConfig::default().reporting.report_title;
29376        assert_eq!(apply(&blank_form()).reporting.report_title, original);
29377    }
29378
29379    #[test]
29380    fn report_title_unchanged_when_whitespace_only() {
29381        let original = sloc_config::AppConfig::default().reporting.report_title;
29382        let mut form = blank_form();
29383        form.report_title = Some("   ".to_string());
29384        assert_eq!(
29385            apply(&form).reporting.report_title,
29386            original,
29387            "whitespace-only title must not overwrite the default"
29388        );
29389    }
29390
29391    #[test]
29392    fn report_title_updated_and_trimmed() {
29393        let mut form = blank_form();
29394        form.report_title = Some("  My Project  ".to_string());
29395        assert_eq!(apply(&form).reporting.report_title, "My Project");
29396    }
29397
29398    // ── report_header_footer ──
29399
29400    #[test]
29401    fn header_footer_none_when_absent() {
29402        assert!(apply(&blank_form())
29403            .reporting
29404            .report_header_footer
29405            .is_none());
29406    }
29407
29408    #[test]
29409    fn header_footer_none_when_whitespace_only() {
29410        let mut form = blank_form();
29411        form.report_header_footer = Some("  ".to_string());
29412        assert!(apply(&form).reporting.report_header_footer.is_none());
29413    }
29414
29415    #[test]
29416    fn header_footer_set_and_trimmed() {
29417        let mut form = blank_form();
29418        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
29419        assert_eq!(
29420            apply(&form).reporting.report_header_footer,
29421            Some("Confidential — Internal Use".to_string())
29422        );
29423    }
29424
29425    // ── include_globs / exclude_globs ──
29426
29427    #[test]
29428    fn include_globs_empty_when_absent() {
29429        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
29430    }
29431
29432    #[test]
29433    fn include_globs_newline_separated() {
29434        let mut form = blank_form();
29435        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
29436        assert_eq!(
29437            apply(&form).discovery.include_globs,
29438            vec!["src/**/*.rs", "tests/**/*.rs"]
29439        );
29440    }
29441
29442    #[test]
29443    fn exclude_globs_comma_separated() {
29444        let mut form = blank_form();
29445        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
29446        assert_eq!(
29447            apply(&form).discovery.exclude_globs,
29448            vec!["vendor/**", "node_modules/**"]
29449        );
29450    }
29451
29452    #[test]
29453    fn globs_mixed_separators() {
29454        let mut form = blank_form();
29455        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
29456        assert_eq!(
29457            apply(&form).discovery.exclude_globs,
29458            vec!["a/**", "b/**", "c/**"]
29459        );
29460    }
29461
29462    // ── split_patterns unit tests ──
29463
29464    #[test]
29465    fn split_patterns_none_is_empty() {
29466        assert!(split_patterns(None).is_empty());
29467    }
29468
29469    #[test]
29470    fn split_patterns_empty_string_is_empty() {
29471        assert!(split_patterns(Some("")).is_empty());
29472    }
29473
29474    #[test]
29475    fn split_patterns_whitespace_only_is_empty() {
29476        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
29477    }
29478
29479    #[test]
29480    fn split_patterns_newlines() {
29481        assert_eq!(
29482            split_patterns(Some("a/**\nb/**\nc/**")),
29483            vec!["a/**", "b/**", "c/**"]
29484        );
29485    }
29486
29487    #[test]
29488    fn split_patterns_commas() {
29489        assert_eq!(
29490            split_patterns(Some("a/**,b/**,c/**")),
29491            vec!["a/**", "b/**", "c/**"]
29492        );
29493    }
29494
29495    #[test]
29496    fn split_patterns_mixed() {
29497        assert_eq!(
29498            split_patterns(Some("a/**\nb/**,c/**")),
29499            vec!["a/**", "b/**", "c/**"]
29500        );
29501    }
29502
29503    #[test]
29504    fn split_patterns_trims_whitespace() {
29505        assert_eq!(
29506            split_patterns(Some("  a/**  \n  b/**  ")),
29507            vec!["a/**", "b/**"]
29508        );
29509    }
29510
29511    #[test]
29512    fn split_patterns_filters_empty_entries() {
29513        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
29514    }
29515
29516    #[test]
29517    fn split_patterns_single_entry() {
29518        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
29519    }
29520}
29521
29522#[cfg(test)]
29523mod utility_tests {
29524    use super::*;
29525    use std::net::IpAddr;
29526    use std::time::Duration;
29527
29528    // ── sanitize_project_label ────────────────────────────────────────────────
29529
29530    #[test]
29531    fn sanitize_simple_name() {
29532        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
29533    }
29534
29535    #[test]
29536    fn sanitize_uppercased_lowercased() {
29537        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
29538    }
29539
29540    #[test]
29541    fn sanitize_path_extracts_filename() {
29542        assert_eq!(
29543            sanitize_project_label("/home/user/my-project"),
29544            "my-project"
29545        );
29546    }
29547
29548    #[test]
29549    fn sanitize_path_uses_last_component() {
29550        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
29551    }
29552
29553    #[test]
29554    fn sanitize_spaces_become_hyphens() {
29555        assert_eq!(sanitize_project_label("my project"), "my-project");
29556    }
29557
29558    #[test]
29559    fn sanitize_non_ascii_become_hyphens() {
29560        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
29561    }
29562
29563    #[test]
29564    fn sanitize_all_special_chars_gives_project() {
29565        assert_eq!(sanitize_project_label("!@#$%^"), "project");
29566    }
29567
29568    #[test]
29569    fn sanitize_empty_string_gives_project() {
29570        assert_eq!(sanitize_project_label(""), "project");
29571    }
29572
29573    #[test]
29574    fn sanitize_leading_trailing_hyphens_stripped() {
29575        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
29576    }
29577
29578    #[test]
29579    fn sanitize_alphanumeric_preserved() {
29580        assert_eq!(sanitize_project_label("repo123"), "repo123");
29581    }
29582
29583    #[test]
29584    fn sanitize_dots_become_hyphens() {
29585        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
29586    }
29587
29588    #[test]
29589    fn sanitize_mixed_slashes_uses_filename() {
29590        // The Windows path separator — on all platforms Path::file_name still works
29591        assert_eq!(sanitize_project_label("project-name"), "project-name");
29592    }
29593
29594    // ── IpRateLimiter ─────────────────────────────────────────────────────────
29595
29596    #[test]
29597    fn rate_limiter_allows_first_request() {
29598        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
29599        let ip: IpAddr = "127.0.0.1".parse().unwrap();
29600        assert!(rl.is_allowed(ip));
29601    }
29602
29603    #[test]
29604    fn rate_limiter_blocks_after_limit_reached() {
29605        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
29606        let ip: IpAddr = "10.0.0.1".parse().unwrap();
29607        assert!(rl.is_allowed(ip));
29608        assert!(rl.is_allowed(ip));
29609        assert!(rl.is_allowed(ip));
29610        assert!(!rl.is_allowed(ip), "4th request must be blocked");
29611    }
29612
29613    #[test]
29614    fn rate_limiter_allows_requests_up_to_limit() {
29615        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
29616        let ip: IpAddr = "10.0.0.2".parse().unwrap();
29617        for _ in 0..5 {
29618            assert!(rl.is_allowed(ip));
29619        }
29620        assert!(!rl.is_allowed(ip), "6th request must be blocked");
29621    }
29622
29623    #[test]
29624    fn rate_limiter_different_ips_are_independent() {
29625        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
29626        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
29627        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
29628        assert!(rl.is_allowed(ip1));
29629        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
29630        assert!(rl.is_allowed(ip2), "ip2 must be independent");
29631    }
29632
29633    #[test]
29634    fn rate_limiter_auth_failure_not_locked_below_threshold() {
29635        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29636        let ip: IpAddr = "10.0.0.3".parse().unwrap();
29637        rl.record_auth_failure(ip);
29638        rl.record_auth_failure(ip);
29639        assert!(
29640            !rl.is_auth_locked_out(ip),
29641            "not locked at 2 failures when threshold is 3"
29642        );
29643    }
29644
29645    #[test]
29646    fn rate_limiter_auth_failure_locked_at_threshold() {
29647        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29648        let ip: IpAddr = "10.0.0.4".parse().unwrap();
29649        rl.record_auth_failure(ip);
29650        rl.record_auth_failure(ip);
29651        rl.record_auth_failure(ip);
29652        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
29653    }
29654
29655    #[test]
29656    fn rate_limiter_auth_failure_different_ips_independent() {
29657        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
29658        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
29659        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
29660        rl.record_auth_failure(ip1);
29661        rl.record_auth_failure(ip1);
29662        assert!(rl.is_auth_locked_out(ip1));
29663        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
29664    }
29665
29666    #[test]
29667    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
29668        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
29669        let ip: IpAddr = "127.0.0.2".parse().unwrap();
29670        for _ in 0..100 {
29671            assert!(rl.is_allowed(ip));
29672        }
29673    }
29674
29675    // ── strip_unc_prefix ──────────────────────────────────────────────────────
29676
29677    #[test]
29678    fn strip_unc_plain_path_unchanged() {
29679        let p = PathBuf::from("C:\\Users\\user\\project");
29680        let result = strip_unc_prefix(p.clone());
29681        assert_eq!(result, p);
29682    }
29683
29684    #[test]
29685    fn strip_unc_with_drive_prefix_stripped() {
29686        let p = PathBuf::from(r"\\?\C:\Users\user\project");
29687        let result = strip_unc_prefix(p);
29688        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
29689    }
29690
29691    #[test]
29692    fn strip_unc_with_network_prefix_stripped() {
29693        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
29694        let result = strip_unc_prefix(p);
29695        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
29696    }
29697
29698    #[test]
29699    fn strip_unc_linux_path_unchanged() {
29700        let p = PathBuf::from("/home/user/project");
29701        let result = strip_unc_prefix(p.clone());
29702        assert_eq!(result, p);
29703    }
29704
29705    // ── remote_to_commit_url ──────────────────────────────────────────────────
29706
29707    #[test]
29708    fn remote_to_commit_url_github_https() {
29709        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
29710        assert_eq!(
29711            url,
29712            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29713        );
29714    }
29715
29716    #[test]
29717    fn remote_to_commit_url_github_ssh() {
29718        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
29719        assert_eq!(
29720            url,
29721            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29722        );
29723    }
29724
29725    #[test]
29726    fn remote_to_commit_url_gitlab_uses_dash_commit() {
29727        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
29728        assert_eq!(
29729            url,
29730            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
29731        );
29732    }
29733
29734    #[test]
29735    fn remote_to_commit_url_bitbucket_uses_commits() {
29736        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
29737        assert_eq!(
29738            url,
29739            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
29740        );
29741    }
29742
29743    #[test]
29744    fn remote_to_commit_url_unknown_scheme_returns_none() {
29745        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
29746        assert!(url.is_none());
29747    }
29748
29749    #[test]
29750    fn remote_to_commit_url_ssh_gitlab() {
29751        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
29752        assert!(url.is_some());
29753        let u = url.unwrap();
29754        assert!(
29755            u.contains("/-/commit/sha123"),
29756            "gitlab ssh must use /-/commit/"
29757        );
29758    }
29759
29760    // ── git_clone_dest ────────────────────────────────────────────────────────
29761
29762    #[test]
29763    fn git_clone_dest_github_url_produces_safe_name() {
29764        let dir = PathBuf::from("/tmp/clones");
29765        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29766        let name = dest.file_name().unwrap().to_string_lossy();
29767        assert!(!name.is_empty());
29768        assert!(
29769            name.chars()
29770                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
29771            "clone dest must only contain safe chars, got: {name}"
29772        );
29773    }
29774
29775    #[test]
29776    fn git_clone_dest_is_inside_clones_dir() {
29777        let dir = PathBuf::from("/tmp/clones");
29778        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29779        assert!(
29780            dest.starts_with(&dir),
29781            "clone dest must be inside clones_dir"
29782        );
29783    }
29784
29785    #[test]
29786    fn git_clone_dest_truncates_to_80_chars_max() {
29787        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
29788        let dir = PathBuf::from("/tmp/clones");
29789        let dest = git_clone_dest(&long_url, &dir);
29790        let name = dest.file_name().unwrap().to_string_lossy();
29791        assert!(
29792            name.len() <= 80,
29793            "clone dest name must be at most 80 chars, got {} chars: {name}",
29794            name.len()
29795        );
29796    }
29797
29798    #[test]
29799    fn git_clone_dest_special_chars_replaced_with_underscore() {
29800        let dir = PathBuf::from("/tmp/clones");
29801        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
29802        let name = dest.file_name().unwrap().to_string_lossy();
29803        assert!(
29804            !name.contains('@') && !name.contains(':') && !name.contains('/'),
29805            "special chars must be replaced in clone dest, got: {name}"
29806        );
29807    }
29808
29809    #[test]
29810    fn git_clone_dest_different_urls_differ() {
29811        let dir = PathBuf::from("/tmp/clones");
29812        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
29813        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
29814        assert_ne!(
29815            a, b,
29816            "different repos must produce different clone dest names"
29817        );
29818    }
29819
29820    #[test]
29821    fn git_clone_dest_same_url_same_result() {
29822        let dir = PathBuf::from("/tmp/clones");
29823        let url = "https://github.com/owner/repo.git";
29824        assert_eq!(
29825            git_clone_dest(url, &dir),
29826            git_clone_dest(url, &dir),
29827            "same URL must always give same clone dest"
29828        );
29829    }
29830
29831    // ── fmt_delta ─────────────────────────────────────────────────────────────
29832
29833    #[test]
29834    fn fmt_delta_positive_has_plus_prefix() {
29835        assert_eq!(fmt_delta(5), "+5");
29836    }
29837
29838    #[test]
29839    fn fmt_delta_negative_no_plus_prefix() {
29840        assert_eq!(fmt_delta(-3), "-3");
29841    }
29842
29843    #[test]
29844    fn fmt_delta_zero() {
29845        assert_eq!(fmt_delta(0), "0");
29846    }
29847
29848    // ── delta_class ───────────────────────────────────────────────────────────
29849
29850    #[test]
29851    fn delta_class_positive_is_pos() {
29852        assert_eq!(delta_class(1), "pos");
29853    }
29854
29855    #[test]
29856    fn delta_class_negative_is_neg() {
29857        assert_eq!(delta_class(-1), "neg");
29858    }
29859
29860    #[test]
29861    fn delta_class_zero_is_zero_class() {
29862        assert_eq!(delta_class(0), "zero");
29863    }
29864
29865    // ── fmt_pct ───────────────────────────────────────────────────────────────
29866
29867    #[test]
29868    fn fmt_pct_zero_baseline_returns_em_dash() {
29869        assert_eq!(fmt_pct(100, 0), "\u{2014}");
29870    }
29871
29872    #[test]
29873    fn fmt_pct_positive_delta_has_plus_sign() {
29874        let result = fmt_pct(10, 100);
29875        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
29876    }
29877
29878    #[test]
29879    fn fmt_pct_negative_delta_no_plus_sign() {
29880        let result = fmt_pct(-10, 100);
29881        assert!(!result.starts_with('+'), "unexpected + in: {result}");
29882        assert!(result.contains('%'));
29883    }
29884
29885    #[test]
29886    fn fmt_pct_near_zero_returns_pm_zero() {
29887        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
29888    }
29889
29890    // ── summary_delta ─────────────────────────────────────────────────────────
29891
29892    #[test]
29893    fn summary_delta_no_prev_returns_dash_na() {
29894        let (display, class) = summary_delta(10, None);
29895        assert_eq!(display, "\u{2014}");
29896        assert_eq!(class, "na");
29897    }
29898
29899    #[test]
29900    fn summary_delta_increase_is_positive() {
29901        let (display, class) = summary_delta(15, Some(10));
29902        assert_eq!(display, "+5");
29903        assert_eq!(class, "pos");
29904    }
29905
29906    #[test]
29907    fn summary_delta_decrease_is_negative() {
29908        let (display, class) = summary_delta(5, Some(10));
29909        assert_eq!(display, "-5");
29910        assert_eq!(class, "neg");
29911    }
29912
29913    // ── nth_weekday_of_month ──────────────────────────────────────────────────
29914
29915    #[test]
29916    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
29917        use chrono::Datelike;
29918        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
29919        assert_eq!(d.year(), 2024);
29920        assert_eq!(d.month(), 1);
29921        assert_eq!(d.weekday(), chrono::Weekday::Mon);
29922        assert!(d.day() <= 7);
29923    }
29924
29925    #[test]
29926    fn nth_weekday_second_sunday_march_2024_is_10th() {
29927        use chrono::Datelike;
29928        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
29929        assert_eq!(d.weekday(), chrono::Weekday::Sun);
29930        assert_eq!(d.month(), 3);
29931        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
29932    }
29933
29934    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
29935
29936    #[test]
29937    fn is_pacific_dst_july_is_true() {
29938        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29939        assert!(is_pacific_dst(dt), "July must be PDT");
29940    }
29941
29942    #[test]
29943    fn is_pacific_dst_january_is_false() {
29944        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29945        assert!(!is_pacific_dst(dt), "January must be PST");
29946    }
29947
29948    #[test]
29949    fn fmt_la_time_summer_shows_pdt() {
29950        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29951        let result = fmt_la_time(dt);
29952        assert!(
29953            result.ends_with("PDT"),
29954            "summer must use PDT, got: {result}"
29955        );
29956    }
29957
29958    #[test]
29959    fn fmt_la_time_winter_shows_pst() {
29960        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29961        let result = fmt_la_time(dt);
29962        assert!(
29963            result.ends_with("PST"),
29964            "winter must use PST, got: {result}"
29965        );
29966    }
29967
29968    #[test]
29969    fn fmt_la_time_meta_summer_shows_pdt() {
29970        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
29971        let result = fmt_la_time_meta(dt);
29972        assert!(
29973            result.ends_with("PDT"),
29974            "meta summer must use PDT, got: {result}"
29975        );
29976    }
29977
29978    #[test]
29979    fn fmt_la_time_meta_winter_shows_pst() {
29980        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
29981        let result = fmt_la_time_meta(dt);
29982        assert!(
29983            result.ends_with("PST"),
29984            "meta winter must use PST, got: {result}"
29985        );
29986    }
29987
29988    // ── fmt_git_date ──────────────────────────────────────────────────────────
29989
29990    #[test]
29991    fn fmt_git_date_valid_iso_returns_some() {
29992        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
29993    }
29994
29995    #[test]
29996    fn fmt_git_date_invalid_returns_none() {
29997        assert!(fmt_git_date("not-a-date").is_none());
29998    }
29999
30000    // ── format_number ─────────────────────────────────────────────────────────
30001
30002    #[test]
30003    fn format_number_zero() {
30004        assert_eq!(format_number(0), "0");
30005    }
30006
30007    #[test]
30008    fn format_number_three_digits_no_comma() {
30009        assert_eq!(format_number(999), "999");
30010    }
30011
30012    #[test]
30013    fn format_number_four_digits_has_comma() {
30014        assert_eq!(format_number(1000), "1,000");
30015    }
30016
30017    #[test]
30018    fn format_number_seven_digits_two_commas() {
30019        assert_eq!(format_number(1_234_567), "1,234,567");
30020    }
30021
30022    #[test]
30023    fn format_number_one_million() {
30024        assert_eq!(format_number(1_000_000), "1,000,000");
30025    }
30026
30027    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
30028
30029    #[test]
30030    fn badge_text_px_empty_is_zero() {
30031        assert_eq!(badge_text_px(""), 0);
30032    }
30033
30034    #[test]
30035    fn badge_text_px_narrow_chars_smaller_than_normal() {
30036        assert!(
30037            badge_text_px("if") < badge_text_px("ab"),
30038            "'if' must be narrower than 'ab'"
30039        );
30040    }
30041
30042    #[test]
30043    fn badge_text_px_m_is_wider_than_a() {
30044        assert!(
30045            badge_text_px("m") > badge_text_px("a"),
30046            "'m' must be wider than 'a'"
30047        );
30048    }
30049
30050    #[test]
30051    fn render_badge_svg_contains_label_and_value() {
30052        let svg = render_badge_svg("coverage", "95%", "#4c1");
30053        assert!(svg.contains("coverage") && svg.contains("95%"));
30054    }
30055
30056    #[test]
30057    fn render_badge_svg_contains_color() {
30058        let svg = render_badge_svg("sloc", "12K", "#e05d44");
30059        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
30060    }
30061
30062    #[test]
30063    fn render_badge_svg_escapes_ampersand_in_label() {
30064        let svg = render_badge_svg("test&label", "ok", "#4c1");
30065        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
30066    }
30067
30068    // ── build_pdf_filename ────────────────────────────────────────────────────
30069
30070    #[test]
30071    fn build_pdf_filename_slugifies_title() {
30072        let name = build_pdf_filename("My Project Report", "abc-def-1234");
30073        assert!(
30074            name.starts_with("my_project_report_")
30075                && std::path::Path::new(&name)
30076                    .extension()
30077                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30078        );
30079    }
30080
30081    #[test]
30082    fn build_pdf_filename_uses_last_run_id_segment() {
30083        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
30084        assert!(name.contains("ABCD"), "must use last segment of run_id");
30085    }
30086
30087    #[test]
30088    fn build_pdf_filename_empty_title_uses_report_prefix() {
30089        let name = build_pdf_filename("", "abc-def-9999");
30090        assert!(
30091            name.starts_with("report_")
30092                && std::path::Path::new(&name)
30093                    .extension()
30094                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30095        );
30096    }
30097
30098    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
30099
30100    #[test]
30101    fn swap_chart_js_replaces_inline_block() {
30102        let html = "<html><head><script>// inline source</script></head><body></body></html>";
30103        let result = swap_inline_chart_js_for_static(html.to_string());
30104        assert!(result.contains(r#"src="/static/chart-report.js""#));
30105        assert!(!result.contains("inline source"));
30106    }
30107
30108    #[test]
30109    fn swap_chart_js_no_head_returns_unchanged() {
30110        let html = "<body>no head here</body>";
30111        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
30112    }
30113
30114    #[test]
30115    fn swap_chart_js_no_script_in_head_unchanged() {
30116        let html = "<html><head><style>.x{}</style></head><body></body></html>";
30117        let result = swap_inline_chart_js_for_static(html.to_string());
30118        assert!(!result.contains("chart-report.js"));
30119    }
30120
30121    // ── patch_html_nonce ──────────────────────────────────────────────────────
30122
30123    #[test]
30124    fn patch_html_nonce_replaces_old_nonce() {
30125        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
30126        let result = patch_html_nonce(html, "new-nonce-456");
30127        assert!(result.contains(r#"nonce="new-nonce-456""#));
30128        assert!(!result.contains("old-nonce-123"));
30129    }
30130
30131    #[test]
30132    fn patch_html_nonce_injects_into_bare_style() {
30133        let html = "<style>body{color:red;}</style>";
30134        let result = patch_html_nonce(html, "fresh-nonce");
30135        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
30136    }
30137
30138    #[test]
30139    fn patch_html_nonce_injects_into_bare_script() {
30140        let html = "<script>console.log(1);</script>";
30141        let result = patch_html_nonce(html, "abc");
30142        assert!(result.contains(r#"<script nonce="abc">"#));
30143    }
30144
30145    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
30146
30147    #[test]
30148    fn is_html_report_file_result_html_matches() {
30149        let dir = tempfile::tempdir().unwrap();
30150        let path = dir.path().join("result_20240101.html");
30151        std::fs::write(&path, b"<html></html>").unwrap();
30152        assert!(is_html_report_file(&path));
30153    }
30154
30155    #[test]
30156    fn is_html_report_file_report_html_matches() {
30157        let dir = tempfile::tempdir().unwrap();
30158        let path = dir.path().join("report_abc.html");
30159        std::fs::write(&path, b"<html></html>").unwrap();
30160        assert!(is_html_report_file(&path));
30161    }
30162
30163    #[test]
30164    fn is_html_report_file_index_html_does_not_match() {
30165        let dir = tempfile::tempdir().unwrap();
30166        let path = dir.path().join("index.html");
30167        std::fs::write(&path, b"<html></html>").unwrap();
30168        assert!(!is_html_report_file(&path));
30169    }
30170
30171    #[test]
30172    fn is_html_report_file_nonexistent_returns_false() {
30173        assert!(!is_html_report_file(Path::new(
30174            "/nonexistent/result_xyz.html"
30175        )));
30176    }
30177
30178    #[test]
30179    fn find_html_report_in_dir_finds_result_html() {
30180        let dir = tempfile::tempdir().unwrap();
30181        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
30182        assert!(find_html_report_in_dir(dir.path()).is_some());
30183    }
30184
30185    #[test]
30186    fn find_html_report_in_dir_empty_returns_none() {
30187        let dir = tempfile::tempdir().unwrap();
30188        assert!(find_html_report_in_dir(dir.path()).is_none());
30189    }
30190
30191    #[test]
30192    fn find_html_report_in_tree_finds_in_subdir() {
30193        let dir = tempfile::tempdir().unwrap();
30194        let subdir = dir.path().join("run-001");
30195        std::fs::create_dir_all(&subdir).unwrap();
30196        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
30197        assert!(find_html_report_in_tree(dir.path()).is_some());
30198    }
30199
30200    // ── derive_project_label ──────────────────────────────────────────────────
30201
30202    #[test]
30203    fn derive_project_label_with_git_repo_and_ref() {
30204        let label = derive_project_label(
30205            Some("https://github.com/owner/my-repo.git"),
30206            Some("main"),
30207            "/fallback/path",
30208        );
30209        assert!(!label.is_empty(), "label must not be empty");
30210        assert!(
30211            label.contains("my") || label.contains("repo"),
30212            "got: {label}"
30213        );
30214    }
30215
30216    #[test]
30217    fn derive_project_label_fallback_to_path() {
30218        let label = derive_project_label(None, None, "/path/to/myproject");
30219        assert_eq!(label, "myproject");
30220    }
30221
30222    #[test]
30223    fn derive_project_label_empty_git_fields_use_path() {
30224        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
30225        assert_eq!(label, "cool-app");
30226    }
30227
30228    // ── derive_file_stem ──────────────────────────────────────────────────────
30229
30230    #[test]
30231    fn derive_file_stem_with_commit_appends_sha() {
30232        assert_eq!(
30233            derive_file_stem("myproject", Some("a1b2c3")),
30234            "myproject_a1b2c3"
30235        );
30236    }
30237
30238    #[test]
30239    fn derive_file_stem_without_commit_returns_label() {
30240        assert_eq!(derive_file_stem("myproject", None), "myproject");
30241    }
30242
30243    #[test]
30244    fn derive_file_stem_empty_commit_returns_label() {
30245        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
30246    }
30247
30248    // ── split_patterns ────────────────────────────────────────────────────────
30249
30250    #[test]
30251    fn split_patterns_none_is_empty() {
30252        assert!(split_patterns(None).is_empty());
30253    }
30254
30255    #[test]
30256    fn split_patterns_empty_string_is_empty() {
30257        assert!(split_patterns(Some("")).is_empty());
30258    }
30259
30260    #[test]
30261    fn split_patterns_comma_separated() {
30262        assert_eq!(
30263            split_patterns(Some("foo,bar,baz")),
30264            vec!["foo", "bar", "baz"]
30265        );
30266    }
30267
30268    #[test]
30269    fn split_patterns_newline_separated() {
30270        assert_eq!(
30271            split_patterns(Some("foo\nbar\nbaz")),
30272            vec!["foo", "bar", "baz"]
30273        );
30274    }
30275
30276    #[test]
30277    fn split_patterns_trims_whitespace() {
30278        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
30279    }
30280
30281    // ── make_git_label ────────────────────────────────────────────────────────
30282
30283    #[test]
30284    fn make_git_label_empty_repo_empty_result() {
30285        assert_eq!(make_git_label("", "main"), "");
30286    }
30287
30288    #[test]
30289    fn make_git_label_empty_ref_empty_result() {
30290        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
30291    }
30292
30293    #[test]
30294    fn make_git_label_basic_format() {
30295        assert_eq!(
30296            make_git_label("https://github.com/owner/my-repo.git", "main"),
30297            "my-repo_at_main_sloc"
30298        );
30299    }
30300
30301    #[test]
30302    fn make_git_label_slash_in_ref_replaced() {
30303        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
30304        assert!(
30305            !label.contains('/'),
30306            "slash in ref must be replaced: {label}"
30307        );
30308    }
30309
30310    // ── format_dir_size ───────────────────────────────────────────────────────
30311
30312    #[test]
30313    fn format_dir_size_bytes() {
30314        assert_eq!(format_dir_size(500), "500 B");
30315    }
30316
30317    #[test]
30318    fn format_dir_size_kilobytes() {
30319        assert_eq!(format_dir_size(2048), "2 KB");
30320    }
30321
30322    #[test]
30323    fn format_dir_size_megabytes() {
30324        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
30325    }
30326
30327    #[test]
30328    fn format_dir_size_gigabytes() {
30329        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
30330    }
30331
30332    #[test]
30333    fn format_dir_size_zero() {
30334        assert_eq!(format_dir_size(0), "0 B");
30335    }
30336
30337    // ── civil_from_days ───────────────────────────────────────────────────────
30338
30339    #[test]
30340    fn civil_from_days_epoch() {
30341        assert_eq!(civil_from_days(0), (1970, 1, 1));
30342    }
30343
30344    #[test]
30345    fn civil_from_days_one_year_later() {
30346        assert_eq!(civil_from_days(365), (1971, 1, 1));
30347    }
30348
30349    #[test]
30350    fn civil_from_days_31_days_is_feb_1_1970() {
30351        assert_eq!(civil_from_days(31), (1970, 2, 1));
30352    }
30353
30354    // ── format_system_time ────────────────────────────────────────────────────
30355
30356    #[test]
30357    fn format_system_time_unix_epoch_formats_correctly() {
30358        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
30359    }
30360
30361    #[test]
30362    fn format_system_time_31_days_after_epoch() {
30363        let t = UNIX_EPOCH + Duration::from_hours(744);
30364        assert_eq!(format_system_time(t), "1970-02-01 00:00");
30365    }
30366
30367    #[test]
30368    fn format_system_time_before_epoch_returns_dash() {
30369        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
30370            assert_eq!(format_system_time(before), "-");
30371        }
30372    }
30373
30374    // ── detect_language_name ──────────────────────────────────────────────────
30375
30376    #[test]
30377    fn detect_language_name_dot_c() {
30378        assert_eq!(detect_language_name("main.c"), Some("C"));
30379    }
30380
30381    #[test]
30382    fn detect_language_name_dot_h() {
30383        assert_eq!(detect_language_name("defs.h"), Some("C"));
30384    }
30385
30386    #[test]
30387    fn detect_language_name_dot_cpp() {
30388        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
30389    }
30390
30391    #[test]
30392    fn detect_language_name_dot_py() {
30393        assert_eq!(detect_language_name("script.py"), Some("Python"));
30394    }
30395
30396    #[test]
30397    fn detect_language_name_dot_ps1() {
30398        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
30399    }
30400
30401    #[test]
30402    fn detect_language_name_dot_cs() {
30403        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
30404    }
30405
30406    #[test]
30407    fn detect_language_name_dot_sh() {
30408        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
30409    }
30410
30411    #[test]
30412    fn detect_language_name_unknown_txt() {
30413        assert_eq!(detect_language_name("notes.txt"), None);
30414    }
30415
30416    // ── language_icon_file ────────────────────────────────────────────────────
30417
30418    #[test]
30419    fn language_icon_file_c() {
30420        assert_eq!(language_icon_file("C"), Some("c.png"));
30421    }
30422
30423    #[test]
30424    fn language_icon_file_python() {
30425        assert_eq!(language_icon_file("Python"), Some("python.png"));
30426    }
30427
30428    #[test]
30429    fn language_icon_file_dockerfile() {
30430        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
30431    }
30432
30433    #[test]
30434    fn language_icon_file_rust_is_none() {
30435        assert!(language_icon_file("Rust").is_none());
30436    }
30437
30438    #[test]
30439    fn language_icon_file_unknown_is_none() {
30440        assert!(language_icon_file("Fortran").is_none());
30441    }
30442
30443    // ── language_inline_svg ───────────────────────────────────────────────────
30444
30445    #[test]
30446    fn language_inline_svg_rust_is_svg() {
30447        let svg = language_inline_svg("Rust").unwrap();
30448        assert!(svg.starts_with("<svg"));
30449    }
30450
30451    #[test]
30452    fn language_inline_svg_typescript_is_some() {
30453        assert!(language_inline_svg("TypeScript").is_some());
30454    }
30455
30456    #[test]
30457    fn language_inline_svg_unknown_is_none() {
30458        assert!(language_inline_svg("Fortran").is_none());
30459    }
30460
30461    // ── classify_preview_file ─────────────────────────────────────────────────
30462
30463    #[test]
30464    fn classify_preview_file_c_supported() {
30465        assert!(matches!(
30466            classify_preview_file("main.c"),
30467            PreviewKind::Supported
30468        ));
30469    }
30470
30471    #[test]
30472    fn classify_preview_file_python_supported() {
30473        assert!(matches!(
30474            classify_preview_file("script.py"),
30475            PreviewKind::Supported
30476        ));
30477    }
30478
30479    #[test]
30480    fn classify_preview_file_png_skipped() {
30481        assert!(matches!(
30482            classify_preview_file("image.png"),
30483            PreviewKind::Skipped
30484        ));
30485    }
30486
30487    #[test]
30488    fn classify_preview_file_zip_skipped() {
30489        assert!(matches!(
30490            classify_preview_file("archive.zip"),
30491            PreviewKind::Skipped
30492        ));
30493    }
30494
30495    #[test]
30496    fn classify_preview_file_min_js_skipped() {
30497        assert!(matches!(
30498            classify_preview_file("bundle.min.js"),
30499            PreviewKind::Skipped
30500        ));
30501    }
30502
30503    #[test]
30504    fn classify_preview_file_rs_unsupported() {
30505        assert!(matches!(
30506            classify_preview_file("main.rs"),
30507            PreviewKind::Unsupported
30508        ));
30509    }
30510
30511    // ── preview_relative_path ─────────────────────────────────────────────────
30512
30513    #[test]
30514    fn preview_relative_path_strips_root() {
30515        let root = PathBuf::from("/project");
30516        let path = PathBuf::from("/project/src/main.c");
30517        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
30518    }
30519
30520    #[test]
30521    fn preview_relative_path_unrooted_includes_filename() {
30522        let root = PathBuf::from("/other");
30523        let path = PathBuf::from("/project/src/main.c");
30524        let result = preview_relative_path(&root, &path);
30525        assert!(result.contains("main.c"));
30526    }
30527
30528    #[test]
30529    fn preview_relative_path_uses_forward_slashes() {
30530        let root = PathBuf::from("/project");
30531        let path = PathBuf::from("/project/a/b/c.py");
30532        assert!(!preview_relative_path(&root, &path).contains('\\'));
30533    }
30534
30535    // ── wildcard_match ────────────────────────────────────────────────────────
30536
30537    #[test]
30538    fn wildcard_match_exact_equal() {
30539        assert!(wildcard_match("foo", "foo"));
30540    }
30541
30542    #[test]
30543    fn wildcard_match_exact_mismatch() {
30544        assert!(!wildcard_match("foo", "bar"));
30545    }
30546
30547    #[test]
30548    fn wildcard_match_star_suffix() {
30549        assert!(wildcard_match("*.rs", "main.rs"));
30550    }
30551
30552    #[test]
30553    fn wildcard_match_star_middle_requires_suffix() {
30554        assert!(!wildcard_match("a*b", "ac"));
30555    }
30556
30557    #[test]
30558    fn wildcard_match_question_mark_single_char() {
30559        assert!(wildcard_match("f?o", "foo"));
30560    }
30561
30562    #[test]
30563    fn wildcard_match_double_star_nested() {
30564        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
30565    }
30566
30567    #[test]
30568    fn wildcard_match_star_directory_entry() {
30569        assert!(wildcard_match("vendor/*", "vendor/crate"));
30570    }
30571
30572    #[test]
30573    fn wildcard_match_no_cross_prefix() {
30574        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
30575    }
30576
30577    // ── should_skip_preview_directory ────────────────────────────────────────
30578
30579    #[test]
30580    fn should_skip_empty_relative_is_false() {
30581        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
30582    }
30583
30584    #[test]
30585    fn should_skip_matching_pattern() {
30586        assert!(should_skip_preview_directory(
30587            "vendor",
30588            &["vendor".to_string()]
30589        ));
30590    }
30591
30592    #[test]
30593    fn should_skip_non_matching() {
30594        assert!(!should_skip_preview_directory(
30595            "src",
30596            &["vendor".to_string()]
30597        ));
30598    }
30599
30600    #[test]
30601    fn should_skip_wildcard_prefix() {
30602        assert!(should_skip_preview_directory(
30603            "target/debug",
30604            &["target*".to_string()]
30605        ));
30606    }
30607
30608    // ── should_include_preview_file ───────────────────────────────────────────
30609
30610    #[test]
30611    fn should_include_empty_relative_always_true() {
30612        assert!(should_include_preview_file("", &[], &[]));
30613    }
30614
30615    #[test]
30616    fn should_include_no_patterns_includes_all() {
30617        assert!(should_include_preview_file("src/main.c", &[], &[]));
30618    }
30619
30620    #[test]
30621    fn should_include_excluded_by_pattern() {
30622        assert!(!should_include_preview_file(
30623            "vendor/lib.c",
30624            &[],
30625            &["vendor/*".to_string()]
30626        ));
30627    }
30628
30629    #[test]
30630    fn should_include_include_pattern_filters() {
30631        assert!(!should_include_preview_file(
30632            "tests/test_foo.c",
30633            &["src/*".to_string()],
30634            &[]
30635        ));
30636    }
30637
30638    // ── escape_html ───────────────────────────────────────────────────────────
30639
30640    #[test]
30641    fn escape_html_ampersand() {
30642        assert_eq!(escape_html("a&b"), "a&amp;b");
30643    }
30644
30645    #[test]
30646    fn escape_html_angle_brackets() {
30647        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
30648    }
30649
30650    #[test]
30651    fn escape_html_double_quote() {
30652        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
30653    }
30654
30655    #[test]
30656    fn escape_html_single_quote() {
30657        assert_eq!(escape_html("it's"), "it&#39;s");
30658    }
30659
30660    #[test]
30661    fn escape_html_plain_text_unchanged() {
30662        assert_eq!(escape_html("hello world"), "hello world");
30663    }
30664
30665    // ── sum_added / removed / unmodified code lines ───────────────────────────
30666
30667    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
30668        sloc_core::ScanComparison {
30669            summary: sloc_core::SummaryDelta {
30670                baseline_run_id: "base".to_string(),
30671                current_run_id: "curr".to_string(),
30672                baseline_timestamp: chrono::Utc::now(),
30673                current_timestamp: chrono::Utc::now(),
30674                baseline_files: 4,
30675                current_files: 4,
30676                files_analyzed_delta: 0,
30677                baseline_code: 330,
30678                current_code: 400,
30679                code_lines_delta: 70,
30680                baseline_comments: 0,
30681                current_comments: 0,
30682                comment_lines_delta: 0,
30683                blank_lines_delta: 0,
30684                total_lines_delta: 70,
30685                coverage_lines_hit_delta: None,
30686                coverage_line_pct_delta: None,
30687                baseline_coverage_line_pct: None,
30688                current_coverage_line_pct: None,
30689            },
30690            file_deltas: vec![
30691                sloc_core::FileDelta {
30692                    relative_path: "added.rs".to_string(),
30693                    language: Some("Rust".to_string()),
30694                    status: FileChangeStatus::Added,
30695                    baseline_code: 0,
30696                    current_code: 100,
30697                    code_delta: 100,
30698                    baseline_comment: 0,
30699                    current_comment: 0,
30700                    comment_delta: 0,
30701                    baseline_blank: 0,
30702                    current_blank: 0,
30703                    blank_delta: 0,
30704                    total_delta: 100,
30705                },
30706                sloc_core::FileDelta {
30707                    relative_path: "removed.rs".to_string(),
30708                    language: Some("Rust".to_string()),
30709                    status: FileChangeStatus::Removed,
30710                    baseline_code: 50,
30711                    current_code: 0,
30712                    code_delta: -50,
30713                    baseline_comment: 0,
30714                    current_comment: 0,
30715                    comment_delta: 0,
30716                    baseline_blank: 0,
30717                    current_blank: 0,
30718                    blank_delta: 0,
30719                    total_delta: -50,
30720                },
30721                sloc_core::FileDelta {
30722                    relative_path: "modified.rs".to_string(),
30723                    language: Some("Rust".to_string()),
30724                    status: FileChangeStatus::Modified,
30725                    baseline_code: 80,
30726                    current_code: 100,
30727                    code_delta: 20,
30728                    baseline_comment: 0,
30729                    current_comment: 0,
30730                    comment_delta: 0,
30731                    baseline_blank: 0,
30732                    current_blank: 0,
30733                    blank_delta: 0,
30734                    total_delta: 20,
30735                },
30736                sloc_core::FileDelta {
30737                    relative_path: "unchanged.rs".to_string(),
30738                    language: Some("Rust".to_string()),
30739                    status: FileChangeStatus::Unchanged,
30740                    baseline_code: 200,
30741                    current_code: 200,
30742                    code_delta: 0,
30743                    baseline_comment: 0,
30744                    current_comment: 0,
30745                    comment_delta: 0,
30746                    baseline_blank: 0,
30747                    current_blank: 0,
30748                    blank_delta: 0,
30749                    total_delta: 0,
30750                },
30751            ],
30752            files_added: 1,
30753            files_removed: 1,
30754            files_modified: 1,
30755            files_unchanged: 1,
30756        }
30757    }
30758
30759    #[test]
30760    fn sum_added_counts_added_and_positive_modified() {
30761        let cmp = make_mixed_scan_comparison();
30762        assert_eq!(sum_added_code_lines(&cmp), 120);
30763    }
30764
30765    #[test]
30766    fn sum_removed_counts_removed_baseline() {
30767        let cmp = make_mixed_scan_comparison();
30768        assert_eq!(sum_removed_code_lines(&cmp), 50);
30769    }
30770
30771    #[test]
30772    fn sum_unmodified_counts_unchanged_files() {
30773        let cmp = make_mixed_scan_comparison();
30774        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
30775    }
30776
30777    // ── detect_coverage_tool ──────────────────────────────────────────────────
30778
30779    #[test]
30780    fn detect_coverage_tool_rust_project() {
30781        let dir = tempfile::tempdir().unwrap();
30782        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
30783        let (tool, cmd) = detect_coverage_tool(dir.path());
30784        assert_eq!(tool, Some("cargo-llvm-cov"));
30785        assert!(cmd.is_some());
30786    }
30787
30788    #[test]
30789    fn detect_coverage_tool_java_gradle() {
30790        let dir = tempfile::tempdir().unwrap();
30791        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
30792        let (tool, _) = detect_coverage_tool(dir.path());
30793        assert_eq!(tool, Some("jacoco"));
30794    }
30795
30796    #[test]
30797    fn detect_coverage_tool_python_pyproject() {
30798        let dir = tempfile::tempdir().unwrap();
30799        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
30800        let (tool, _) = detect_coverage_tool(dir.path());
30801        assert_eq!(tool, Some("pytest-cov"));
30802    }
30803
30804    #[test]
30805    fn detect_coverage_tool_unknown_project() {
30806        let dir = tempfile::tempdir().unwrap();
30807        let (tool, cmd) = detect_coverage_tool(dir.path());
30808        assert!(tool.is_none() && cmd.is_none());
30809    }
30810
30811    // ── sanitize_path_str / display_path ─────────────────────────────────────
30812
30813    #[test]
30814    fn sanitize_path_str_unc_drive_stripped() {
30815        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
30816    }
30817
30818    #[test]
30819    fn sanitize_path_str_unc_network_stripped() {
30820        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
30821    }
30822
30823    #[test]
30824    fn sanitize_path_str_plain_path_unchanged() {
30825        assert_eq!(
30826            sanitize_path_str("/home/user/project"),
30827            "/home/user/project"
30828        );
30829    }
30830
30831    #[test]
30832    fn display_path_plain_linux_unchanged() {
30833        assert_eq!(
30834            display_path(Path::new("/home/user/project")),
30835            "/home/user/project"
30836        );
30837    }
30838
30839    #[test]
30840    fn display_path_unc_drive_stripped() {
30841        let result = display_path(Path::new(r"\\?\C:\Users\user"));
30842        assert_eq!(result, r"C:\Users\user");
30843    }
30844
30845    #[test]
30846    fn display_path_unc_network_stripped() {
30847        let result = display_path(Path::new(r"\\?\UNC\server\share"));
30848        assert_eq!(result, r"\\server\share");
30849    }
30850}
30851
30852#[cfg(test)]
30853mod coverage_boost_unit_tests {
30854    use super::*;
30855    use std::path::{Path, PathBuf};
30856
30857    // Both scenarios live in one test (sequential, under a Tokio runtime) because
30858    // load_runtime_security_config spawns a pruning task and mutates process-global
30859    // env vars — parallel sub-tests would race on both.
30860    #[tokio::test]
30861    async fn runtime_security_config_scenarios() {
30862        std::env::remove_var("SLOC_API_KEYS");
30863        std::env::remove_var("SLOC_API_KEY");
30864        std::env::remove_var("SLOC_TLS_CERT");
30865        std::env::remove_var("SLOC_TLS_KEY");
30866        std::env::remove_var("SLOC_TRUST_PROXY");
30867        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30868        let cfg = load_runtime_security_config(false);
30869        assert!(cfg.api_keys.is_empty());
30870        assert!(!cfg.tls_enabled);
30871        assert!(!cfg.trust_proxy);
30872
30873        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
30874        std::env::set_var("SLOC_TRUST_PROXY", "1");
30875        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
30876        std::env::set_var("SLOC_RATE_LIMIT", "250");
30877        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
30878        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
30879        let cfg = load_runtime_security_config(true);
30880        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
30881        assert!(cfg.trust_proxy);
30882        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
30883        std::env::remove_var("SLOC_API_KEYS");
30884        std::env::remove_var("SLOC_TRUST_PROXY");
30885        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30886        std::env::remove_var("SLOC_RATE_LIMIT");
30887        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
30888        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
30889    }
30890
30891    #[test]
30892    fn cors_layer_builds_both_modes() {
30893        let _ = build_cors_layer(true);
30894        let _ = build_cors_layer(false);
30895    }
30896
30897    #[test]
30898    fn primary_lan_ip_callable() {
30899        // May be Some or None depending on the host; both are valid.
30900        let _ = primary_lan_ip();
30901    }
30902
30903    #[test]
30904    fn safe_redirect_allows_relative_rejects_absolute() {
30905        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
30906        assert_eq!(safe_redirect("https://evil.example/x"), "/");
30907        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
30908        assert_eq!(default_redirect(), "/view-reports");
30909    }
30910
30911    #[test]
30912    fn tarball_size_caps_env_override() {
30913        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
30914        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
30915        let (c, d) = parse_tarball_size_caps();
30916        assert_eq!(c, 1024 * 1024);
30917        assert_eq!(d, 2 * 1024 * 1024);
30918        std::env::remove_var("SLOC_MAX_TARBALL_MB");
30919        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
30920        let (c2, _) = parse_tarball_size_caps();
30921        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
30922    }
30923
30924    #[test]
30925    fn upload_path_helpers() {
30926        let base = upload_base_dir();
30927        let staged = upload_staging_path("abc123");
30928        assert!(staged.starts_with(&base));
30929        assert!(
30930            is_upload_tmp_path(&staged),
30931            "staging path is an upload tmp path"
30932        );
30933        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
30934    }
30935
30936    #[test]
30937    fn git_clones_dir_env_override() {
30938        std::env::remove_var("SLOC_GIT_CLONES_DIR");
30939        let def = resolve_git_clones_dir(Path::new("/out"));
30940        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
30941        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
30942        assert_eq!(
30943            resolve_git_clones_dir(Path::new("/out")),
30944            PathBuf::from("/custom/clones")
30945        );
30946        std::env::remove_var("SLOC_GIT_CLONES_DIR");
30947    }
30948
30949    #[test]
30950    fn html_report_file_detection() {
30951        let dir = std::env::temp_dir().join("sloc_html_detect");
30952        let _ = std::fs::create_dir_all(&dir);
30953        let good = dir.join("report_x.html");
30954        std::fs::write(&good, "<html></html>").unwrap();
30955        let bad = dir.join("notes.txt");
30956        std::fs::write(&bad, "x").unwrap();
30957        assert!(is_html_report_file(&good));
30958        assert!(!is_html_report_file(&bad));
30959        assert!(find_html_report_in_dir(&dir).is_some());
30960        let _ = std::fs::remove_dir_all(&dir);
30961    }
30962
30963    #[test]
30964    fn multi_delta_class_and_format() {
30965        assert_eq!(multi_delta_class(5), "pos");
30966        assert_eq!(multi_delta_class(-5), "neg");
30967        assert_eq!(multi_delta_class(0), "zero");
30968        assert_eq!(multi_fmt_delta(3), "+3");
30969        assert_eq!(multi_fmt_delta(-3), "-3");
30970        assert_eq!(multi_fmt_delta(0), "0");
30971    }
30972
30973    #[test]
30974    fn git_clone_dest_sanitizes() {
30975        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
30976        assert!(dest.starts_with("/clones"));
30977        let name = dest.file_name().unwrap().to_str().unwrap();
30978        assert!(name
30979            .chars()
30980            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
30981    }
30982}
30983
30984#[cfg(test)]
30985mod tests_private {
30986    use super::*;
30987    use std::io::Read;
30988
30989    #[test]
30990    fn size_limit_reader_zero_remaining_returns_error() {
30991        let data = b"hello world";
30992        let mut reader = SizeLimitReader {
30993            inner: &data[..],
30994            remaining: 0,
30995        };
30996        let mut buf = [0u8; 4];
30997        assert!(reader.read(&mut buf).is_err());
30998    }
30999
31000    #[test]
31001    fn size_limit_reader_counts_bytes() {
31002        let data = b"hello world";
31003        let mut reader = SizeLimitReader {
31004            inner: &data[..],
31005            remaining: 5,
31006        };
31007        let mut buf = [0u8; 4];
31008        let n = reader.read(&mut buf).unwrap();
31009        assert_eq!(n, 4);
31010        assert_eq!(reader.remaining, 1);
31011    }
31012
31013    #[test]
31014    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
31015        let uuid = "12345678-1234-1234-1234-123456789012";
31016        let (id, path) = resolve_or_create_staging(Some(uuid));
31017        assert_eq!(id, uuid);
31018        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
31019    }
31020
31021    #[test]
31022    fn resolve_or_create_staging_with_none_creates_new() {
31023        let (id1, _) = resolve_or_create_staging(None);
31024        let (id2, _) = resolve_or_create_staging(None);
31025        assert_ne!(id1, id2);
31026    }
31027
31028    #[test]
31029    fn resolve_or_create_staging_with_path_separator_creates_new() {
31030        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
31031        let (id, _) = resolve_or_create_staging(Some("has/slash"));
31032        assert_ne!(id, "has/slash");
31033    }
31034
31035    #[test]
31036    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
31037        use std::net::IpAddr;
31038        use std::str::FromStr;
31039        let limiter = IpRateLimiter::new(
31040            Duration::from_secs(60),
31041            100,
31042            5,
31043            Duration::from_secs(300),
31044        );
31045        let ip = IpAddr::from_str("192.168.1.1").unwrap();
31046        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
31047    }
31048
31049    #[test]
31050    fn is_auth_locked_out_expired_entry_removed() {
31051        use std::net::IpAddr;
31052        use std::str::FromStr;
31053        let limiter = IpRateLimiter::new(
31054            Duration::from_secs(60),
31055            100,
31056            1, // 1 failure triggers lockout
31057            Duration::from_millis(1),
31058        );
31059        let ip = IpAddr::from_str("192.168.1.2").unwrap();
31060        limiter.record_auth_failure(ip);
31061        // Wait for the 1ms window to expire
31062        std::thread::sleep(Duration::from_millis(10));
31063        // Expired entry should be removed, returning false
31064        assert!(!limiter.is_auth_locked_out(ip));
31065    }
31066
31067    #[test]
31068    fn is_auth_locked_out_within_window_returns_true() {
31069        use std::net::IpAddr;
31070        use std::str::FromStr;
31071        let limiter = IpRateLimiter::new(
31072            Duration::from_secs(60),
31073            100,
31074            2, // 2 failures triggers lockout
31075            Duration::from_secs(3600),
31076        );
31077        let ip = IpAddr::from_str("192.168.1.3").unwrap();
31078        limiter.record_auth_failure(ip);
31079        limiter.record_auth_failure(ip);
31080        assert!(limiter.is_auth_locked_out(ip));
31081    }
31082}