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
999struct RuntimeSecurityConfig {
1000    api_keys: Vec<secrecy::SecretBox<String>>,
1001    tls_cert: Option<String>,
1002    tls_key: Option<String>,
1003    tls_enabled: bool,
1004    trust_proxy: bool,
1005    trusted_proxy_ips: Vec<IpAddr>,
1006    rate_limiter: Arc<IpRateLimiter>,
1007}
1008
1009fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1010    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1011        .or_else(|_| std::env::var("SLOC_API_KEY"))
1012        .unwrap_or_default()
1013        .split(',')
1014        .map(str::trim)
1015        .filter(|s| !s.is_empty())
1016        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1017        .collect();
1018    if server_mode && api_keys.is_empty() {
1019        println!(
1020            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
1021             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
1022        );
1023    }
1024    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1025    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1026    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1027    if server_mode && !tls_enabled {
1028        println!(
1029            "WARNING: TLS is not configured. Traffic is cleartext. \
1030             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1031             or terminate TLS at a reverse proxy (nginx, caddy)."
1032        );
1033    }
1034    if server_mode {
1035        println!(
1036            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1037             to restrict cross-origin access (comma-separated)."
1038        );
1039    }
1040    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1041    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1042        .unwrap_or_default()
1043        .split(',')
1044        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1045        .collect();
1046    if trust_proxy {
1047        if trusted_proxy_ips.is_empty() {
1048            println!(
1049                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1050                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1051                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1052            );
1053        } else {
1054            println!(
1055                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1056                trusted_proxy_ips
1057                    .iter()
1058                    .map(std::string::ToString::to_string)
1059                    .collect::<Vec<_>>()
1060                    .join(", ")
1061            );
1062        }
1063    } else if server_mode {
1064        println!(
1065            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1066             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1067             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1068             enable per-client rate limiting via X-Forwarded-For."
1069        );
1070    }
1071    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1072        println!(
1073            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1074             DISABLED for all git operations. Remove this variable before production use."
1075        );
1076    }
1077    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1078        .ok()
1079        .and_then(|v| v.parse::<u32>().ok())
1080        .unwrap_or(10);
1081    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1082        .ok()
1083        .and_then(|v| v.parse::<u64>().ok())
1084        .unwrap_or(3600);
1085    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1086    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1087    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1088    let default_rpm: usize = if server_mode { 120 } else { 600 };
1089    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1090        .ok()
1091        .and_then(|v| v.parse::<usize>().ok())
1092        .unwrap_or(default_rpm);
1093    let rate_limiter = Arc::new(IpRateLimiter::new(
1094        Duration::from_mins(1),
1095        rate_limit_rpm,
1096        auth_lockout_threshold,
1097        Duration::from_secs(auth_lockout_secs),
1098    ));
1099    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1100    RuntimeSecurityConfig {
1101        api_keys,
1102        tls_cert,
1103        tls_key,
1104        tls_enabled,
1105        trust_proxy,
1106        trusted_proxy_ips,
1107        rate_limiter,
1108    }
1109}
1110
1111/// # Errors
1112///
1113/// Returns an error if the server fails to bind to the configured address or
1114/// if the TLS configuration cannot be loaded.
1115///
1116/// # Panics
1117///
1118/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1119#[allow(clippy::too_many_lines)]
1120pub async fn serve(config: AppConfig) -> Result<()> {
1121    let bind_address = config.web.bind_address.clone();
1122    let server_mode = config.web.server_mode;
1123    let output_root = resolve_output_root(None);
1124    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1125    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1126        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1127    let mut registry = ScanRegistry::load(&registry_path);
1128    registry.prune_stale();
1129    let _ = registry.save(&registry_path);
1130
1131    let sec = load_runtime_security_config(server_mode);
1132    spawn_upload_staging_cleanup();
1133
1134    let git_clones_dir = resolve_git_clones_dir(&output_root);
1135    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1136        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1137    let schedules = ScheduleStore::load(&schedules_path);
1138    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1139        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1140    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1141    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1142        |_| output_root.join("confluence_config.json"),
1143        PathBuf::from,
1144    );
1145    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1146    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1147        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1148    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1149    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1150        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1151    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1152
1153    let state = AppState {
1154        base_config: config,
1155        artifacts: Arc::new(Mutex::new(HashMap::new())),
1156        async_runs: Arc::new(Mutex::new(HashMap::new())),
1157        registry: Arc::new(Mutex::new(registry)),
1158        registry_path,
1159        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1160        server_mode,
1161        tls_enabled: sec.tls_enabled,
1162        api_keys: Arc::new(sec.api_keys),
1163        rate_limiter: sec.rate_limiter,
1164        trust_proxy: sec.trust_proxy,
1165        trusted_proxy_ips: sec.trusted_proxy_ips,
1166        git_clones_dir,
1167        schedules: Arc::new(Mutex::new(schedules)),
1168        schedules_path,
1169        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1170        scan_profiles_path,
1171        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1172        confluence: Arc::new(Mutex::new(confluence)),
1173        confluence_path,
1174        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1175        watched_dirs_path,
1176        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1177        cleanup_policy_path,
1178        cleanup_task_handle: Arc::new(Mutex::new(None)),
1179    };
1180
1181    restart_poll_schedules(&state).await;
1182
1183    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1184    {
1185        let enabled = state
1186            .cleanup_policy
1187            .lock()
1188            .await
1189            .policy
1190            .as_ref()
1191            .is_some_and(|p| p.enabled);
1192        if enabled {
1193            let handle = spawn_cleanup_policy_task(state.clone());
1194            *state.cleanup_task_handle.lock().await = Some(handle);
1195        }
1196    }
1197
1198    let app = build_router(state.clone());
1199
1200    // Try the configured port first, then step up through a few alternatives.
1201    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1202    // kernel zombie (visible in netstat but owned by no living process).  Rather
1203    // than failing, we auto-select the next free port and tell the user.
1204    let preferred: SocketAddr = bind_address
1205        .parse()
1206        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1207    let (listener, addr) = {
1208        let candidates = (0u16..=9).map(|offset| {
1209            let mut a = preferred;
1210            a.set_port(preferred.port().saturating_add(offset));
1211            a
1212        });
1213        let mut found = None;
1214        for candidate in candidates {
1215            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1216                found = Some((l, candidate));
1217                break;
1218            }
1219        }
1220        found.ok_or_else(|| {
1221            anyhow::anyhow!(
1222                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1223                bind_address,
1224                preferred.port(),
1225                preferred.port().saturating_add(9)
1226            )
1227        })?
1228    };
1229    if addr != preferred {
1230        eprintln!(
1231            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1232             using {} instead.",
1233            preferred.port(),
1234            addr.port()
1235        );
1236    }
1237
1238    if sec.tls_enabled {
1239        let cert_path = sec
1240            .tls_cert
1241            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1242        let key_path = sec
1243            .tls_key
1244            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1245        let tls_config = build_tls_config(&cert_path, &key_path)
1246            .context("failed to load TLS certificate/key")?;
1247        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1248
1249        let url = format!("https://{addr}/");
1250        println!("OxideSLOC server running at {url} (TLS)");
1251        println!("Use Ctrl+C to stop.");
1252
1253        return serve_tls(listener, app, acceptor, server_mode).await;
1254    }
1255
1256    let url = format!("http://{addr}/");
1257    log_startup_url(&url, server_mode);
1258
1259    axum::serve(
1260        listener,
1261        app.into_make_service_with_connect_info::<SocketAddr>(),
1262    )
1263    .with_graceful_shutdown(shutdown_signal(server_mode))
1264    .await
1265    .context("web server terminated unexpectedly")
1266}
1267
1268/// Discover the primary non-loopback IPv4 address by asking the OS which
1269/// outbound interface it would use to reach a public address.  No packets are
1270/// sent — the UDP socket is only used to query the routing table.
1271fn primary_lan_ip() -> Option<String> {
1272    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1273    socket.connect("8.8.8.8:80").ok()?;
1274    let addr = socket.local_addr().ok()?;
1275    let ip = addr.ip();
1276    if ip.is_loopback() {
1277        return None;
1278    }
1279    Some(ip.to_string())
1280}
1281
1282/// Print the startup URL and, in local mode, open the browser and schedule it.
1283fn log_startup_url(url: &str, server_mode: bool) {
1284    if server_mode {
1285        println!("OxideSLOC server running at {url}");
1286        println!("Use Ctrl+C to stop.");
1287    } else {
1288        println!("OxideSLOC local web UI running at {url}");
1289        println!("Press Ctrl+C to stop the server.");
1290        let open_url = url.to_owned();
1291        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1292    }
1293}
1294
1295/// Open the given URL in the default system browser.
1296fn open_browser_tab(url: &str) {
1297    #[cfg(target_os = "windows")]
1298    let _ = std::process::Command::new("cmd")
1299        .args(["/c", "start", "", url])
1300        .stdout(Stdio::null())
1301        .stderr(Stdio::null())
1302        .spawn();
1303    #[cfg(target_os = "macos")]
1304    let _ = std::process::Command::new("open")
1305        .arg(url)
1306        .stdout(Stdio::null())
1307        .stderr(Stdio::null())
1308        .spawn();
1309    #[cfg(target_os = "linux")]
1310    let _ = std::process::Command::new("xdg-open")
1311        .arg(url)
1312        .stdout(Stdio::null())
1313        .stderr(Stdio::null())
1314        .spawn();
1315}
1316
1317/// Graceful-shutdown future: resolves on Ctrl-C.
1318async fn shutdown_signal(server_mode: bool) {
1319    if tokio::signal::ctrl_c().await.is_ok() {
1320        println!();
1321        if server_mode {
1322            println!("Shutting down OxideSLOC server...");
1323        } else {
1324            println!("Shutting down OxideSLOC local web UI...");
1325        }
1326        println!("Server stopped cleanly.");
1327    }
1328}
1329
1330/// Load a rustls `ServerConfig` from PEM certificate and key files.
1331fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1332    use rustls_pki_types::pem::PemObject;
1333    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1334
1335    let cert_bytes =
1336        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1337    let key_bytes =
1338        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1339
1340    let cert_chain: Vec<CertificateDer<'static>> =
1341        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1342            .collect::<std::result::Result<_, _>>()
1343            .context("failed to parse TLS certificates")?;
1344
1345    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1346        .context("failed to parse TLS private key")?;
1347
1348    rustls::ServerConfig::builder()
1349        .with_no_client_auth()
1350        .with_single_cert(cert_chain, key)
1351        .context("failed to build TLS server config")
1352}
1353
1354/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1355async fn serve_tls(
1356    listener: tokio::net::TcpListener,
1357    app: Router,
1358    acceptor: tokio_rustls::TlsAcceptor,
1359    server_mode: bool,
1360) -> Result<()> {
1361    use hyper_util::rt::{TokioExecutor, TokioIo};
1362    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1363    use hyper_util::service::TowerToHyperService;
1364    use tower::{Service, ServiceExt};
1365
1366    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1367
1368    loop {
1369        tokio::select! {
1370            biased;
1371            _ = tokio::signal::ctrl_c() => {
1372                println!();
1373                if server_mode {
1374                    println!("Shutting down OxideSLOC server...");
1375                } else {
1376                    println!("Shutting down OxideSLOC local web UI...");
1377                }
1378                println!("Server stopped cleanly.");
1379                return Ok(());
1380            }
1381            result = listener.accept() => {
1382                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1383                let acceptor = acceptor.clone();
1384                let mut factory = make_svc.clone();
1385
1386                tokio::spawn(async move {
1387                    let tls = match acceptor.accept(tcp).await {
1388                        Ok(s) => s,
1389                        Err(e) => {
1390                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1391                            return;
1392                        }
1393                    };
1394                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1395                        Ok(f) => match Service::call(f, peer_addr).await {
1396                            Ok(s) => s,
1397                            Err(_) => return,
1398                        },
1399                        Err(_) => return,
1400                    };
1401                    let io = TokioIo::new(tls);
1402                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1403                        .serve_connection(io, TowerToHyperService::new(svc))
1404                        .await
1405                    {
1406                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1407                    }
1408                });
1409            }
1410        }
1411    }
1412}
1413
1414// auth moved to auth.rs
1415
1416fn build_cors_layer(server_mode: bool) -> CorsLayer {
1417    if server_mode {
1418        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1419            .unwrap_or_default()
1420            .split(',')
1421            .filter(|s| !s.is_empty())
1422            .filter_map(|s| s.trim().parse().ok())
1423            .collect();
1424        if allowed.is_empty() {
1425            return CorsLayer::new();
1426        }
1427        CorsLayer::new()
1428            .allow_origin(AllowOrigin::list(allowed))
1429            .allow_methods(AllowMethods::list([
1430                axum::http::Method::GET,
1431                axum::http::Method::POST,
1432            ]))
1433            .allow_headers(AllowHeaders::list([
1434                axum::http::header::AUTHORIZATION,
1435                axum::http::header::CONTENT_TYPE,
1436            ]))
1437    } else {
1438        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1439            let s = origin.to_str().unwrap_or("");
1440            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1441        }))
1442    }
1443}
1444
1445async fn add_security_headers(
1446    State(state): State<AppState>,
1447    mut req: Request<Body>,
1448    next: Next,
1449) -> Response {
1450    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1451    req.extensions_mut().insert(CspNonce(nonce.clone()));
1452    let mut resp = next.run(req).await;
1453    let h = resp.headers_mut();
1454    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1455    h.insert(
1456        "X-Content-Type-Options",
1457        HeaderValue::from_static("nosniff"),
1458    );
1459    h.insert(
1460        "Referrer-Policy",
1461        HeaderValue::from_static("strict-origin-when-cross-origin"),
1462    );
1463    let csp = format!(
1464        "default-src 'self'; \
1465         style-src 'self' 'unsafe-inline'; \
1466         img-src 'self' data: blob:; \
1467         script-src 'self' 'nonce-{nonce}'; \
1468         font-src 'self' data:; \
1469         object-src 'none'; \
1470         frame-ancestors 'none'"
1471    );
1472    h.insert(
1473        "Content-Security-Policy",
1474        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1475            HeaderValue::from_static(
1476                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1477            )
1478        }),
1479    );
1480    h.insert(
1481        "X-Permitted-Cross-Domain-Policies",
1482        HeaderValue::from_static("none"),
1483    );
1484    h.insert(
1485        "Permissions-Policy",
1486        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1487    );
1488    h.insert(
1489        "Cross-Origin-Opener-Policy",
1490        HeaderValue::from_static("same-origin"),
1491    );
1492    h.insert(
1493        "Cross-Origin-Resource-Policy",
1494        HeaderValue::from_static("same-origin"),
1495    );
1496    if state.tls_enabled {
1497        h.insert(
1498            "Strict-Transport-Security",
1499            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1500        );
1501    }
1502    resp
1503}
1504
1505async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1506    let peer_ip = req
1507        .extensions()
1508        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1509        .map(|c| c.0.ip());
1510
1511    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1512    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1513    // header spoofing from direct connections.
1514    let ip = peer_ip
1515        .and_then(|peer| {
1516            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1517                req.headers()
1518                    .get("X-Forwarded-For")
1519                    .and_then(|v| v.to_str().ok())
1520                    .and_then(|s| s.split(',').next())
1521                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1522            } else {
1523                None
1524            }
1525        })
1526        .or(peer_ip)
1527        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1528
1529    if !state.rate_limiter.is_allowed(ip) {
1530        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1531            path = %req.uri().path(), "Rate limit exceeded");
1532        return (
1533            StatusCode::TOO_MANY_REQUESTS,
1534            [(header::RETRY_AFTER, "60")],
1535            "429 Too Many Requests\n",
1536        )
1537            .into_response();
1538    }
1539    next.run(req).await
1540}
1541
1542async fn splash(
1543    State(state): State<AppState>,
1544    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1545) -> impl IntoResponse {
1546    let lan_ip = if state.server_mode {
1547        primary_lan_ip()
1548    } else {
1549        None
1550    };
1551    let port = state
1552        .base_config
1553        .web
1554        .bind_address
1555        .rsplit(':')
1556        .next()
1557        .and_then(|p| p.parse::<u16>().ok())
1558        .unwrap_or(4317);
1559    let has_api_key = !state.api_keys.is_empty();
1560    let template = SplashTemplate {
1561        csp_nonce,
1562        server_mode: state.server_mode,
1563        lan_ip,
1564        port,
1565        version: env!("CARGO_PKG_VERSION"),
1566        has_api_key,
1567    };
1568    Html(
1569        template
1570            .render()
1571            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1572    )
1573}
1574
1575async fn index(
1576    State(state): State<AppState>,
1577    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1578    Query(query): Query<IndexQuery>,
1579) -> impl IntoResponse {
1580    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1581        let policy = query
1582            .mixed_line_policy
1583            .unwrap_or_else(|| "code_only".to_string());
1584        let behavior = query
1585            .binary_file_behavior
1586            .unwrap_or_else(|| "skip".to_string());
1587        let cfg = ScanConfig {
1588            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1589            path: query.path.unwrap_or_default(),
1590            include_globs: query.include_globs.unwrap_or_default(),
1591            exclude_globs: query.exclude_globs.unwrap_or_default(),
1592            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1593            mixed_line_policy: policy,
1594            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1595                != Some("off"),
1596            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1597            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1598            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1599                != Some("disabled"),
1600            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1601            binary_file_behavior: behavior,
1602            output_dir: query.output_dir.unwrap_or_default(),
1603            report_title: query.report_title.unwrap_or_default(),
1604        };
1605        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1606    } else {
1607        "{}".to_string()
1608    };
1609
1610    let git_repo = query.git_repo.unwrap_or_default();
1611    let git_ref = query.git_ref.unwrap_or_default();
1612
1613    let git_label = make_git_label(&git_repo, &git_ref);
1614    let git_output_dir = if git_label.is_empty() {
1615        String::new()
1616    } else {
1617        desktop_dir().join(&git_label).display().to_string()
1618    };
1619    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1620    let git_output_dir_json =
1621        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1622
1623    let template = IndexTemplate {
1624        version: env!("CARGO_PKG_VERSION"),
1625        prefill_json,
1626        csp_nonce,
1627        git_repo,
1628        git_ref,
1629        git_label_json,
1630        git_output_dir_json,
1631        server_mode: state.server_mode,
1632    };
1633
1634    Html(
1635        template
1636            .render()
1637            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1638    )
1639}
1640
1641async fn scan_setup_handler(
1642    State(state): State<AppState>,
1643    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1644) -> impl IntoResponse {
1645    let recent_scans_json = {
1646        let arr: Vec<serde_json::Value> = {
1647            let reg = state.registry.lock().await;
1648            reg.entries
1649                .iter()
1650                .rev()
1651                .take(6)
1652                .map(|e| {
1653                    let run_dir = e
1654                        .html_path
1655                        .as_ref()
1656                        .or(e.json_path.as_ref())
1657                        .and_then(|p| p.parent().map(PathBuf::from));
1658                    let config_val: Option<serde_json::Value> = run_dir
1659                        .and_then(|d| find_scan_config_in_dir(&d))
1660                        .and_then(|p| fs::read_to_string(&p).ok())
1661                        .and_then(|s| serde_json::from_str(&s).ok());
1662                    serde_json::json!({
1663                        "project_label": e.project_label,
1664                        "timestamp": fmt_la_time(e.timestamp_utc),
1665                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1666                        "config": config_val,
1667                    })
1668                })
1669                .collect()
1670        };
1671        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1672    };
1673
1674    let template = ScanSetupTemplate {
1675        version: env!("CARGO_PKG_VERSION"),
1676        recent_scans_json,
1677        csp_nonce,
1678    };
1679    Html(
1680        template
1681            .render()
1682            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1683    )
1684}
1685
1686async fn healthz() -> &'static str {
1687    "ok"
1688}
1689
1690async fn api_version_handler() -> impl IntoResponse {
1691    axum::Json(serde_json::json!({
1692        "name": "oxide-sloc",
1693        "version": env!("CARGO_PKG_VERSION"),
1694    }))
1695}
1696
1697// ── Prometheus metrics ────────────────────────────────────────────────────────
1698
1699fn prom_runs_total() -> &'static prometheus::IntCounter {
1700    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1701    COUNTER.get_or_init(|| {
1702        prometheus::register_int_counter!(
1703            "oxide_sloc_runs_total",
1704            "Total number of completed analysis runs"
1705        )
1706        .expect("failed to register oxide_sloc_runs_total counter")
1707    })
1708}
1709
1710async fn metrics_handler() -> impl IntoResponse {
1711    use prometheus::Encoder as _;
1712    let mut buf = Vec::new();
1713    let encoder = prometheus::TextEncoder::new();
1714    let _ = encoder.encode(&prometheus::gather(), &mut buf);
1715    (
1716        [(
1717            axum::http::header::CONTENT_TYPE,
1718            "text/plain; version=0.0.4; charset=utf-8",
1719        )],
1720        buf,
1721    )
1722}
1723
1724static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1725
1726async fn openapi_yaml_handler() -> impl IntoResponse {
1727    (
1728        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1729        OPENAPI_YAML,
1730    )
1731}
1732
1733static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
1734static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
1735
1736async fn llms_txt_handler() -> impl IntoResponse {
1737    (
1738        [
1739            (
1740                axum::http::header::CONTENT_TYPE,
1741                "text/plain; charset=utf-8",
1742            ),
1743            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1744        ],
1745        LLMS_TXT,
1746    )
1747}
1748
1749async fn llms_full_txt_handler() -> impl IntoResponse {
1750    (
1751        [
1752            (
1753                axum::http::header::CONTENT_TYPE,
1754                "text/plain; charset=utf-8",
1755            ),
1756            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1757        ],
1758        LLMS_FULL_TXT,
1759    )
1760}
1761
1762async fn api_docs_handler(
1763    State(state): State<AppState>,
1764    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1765) -> impl IntoResponse {
1766    let has_api_key = !state.api_keys.is_empty();
1767    Html(
1768        ApiDocsTemplate {
1769            has_api_key,
1770            csp_nonce,
1771            version: env!("CARGO_PKG_VERSION"),
1772        }
1773        .render()
1774        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1775    )
1776}
1777
1778async fn chart_js_handler() -> impl IntoResponse {
1779    (
1780        [
1781            (
1782                header::CONTENT_TYPE,
1783                "application/javascript; charset=utf-8",
1784            ),
1785            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1786        ],
1787        CHART_JS,
1788    )
1789}
1790
1791async fn report_chart_js_handler() -> impl IntoResponse {
1792    (
1793        [
1794            (
1795                header::CONTENT_TYPE,
1796                "application/javascript; charset=utf-8",
1797            ),
1798            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1799        ],
1800        REPORT_CHART_JS,
1801    )
1802}
1803
1804#[derive(Debug, Deserialize)]
1805struct AnalyzeForm {
1806    path: String,
1807    git_repo: Option<String>,
1808    git_ref: Option<String>,
1809    mixed_line_policy: Option<MixedLinePolicy>,
1810    python_docstrings_as_comments: Option<String>,
1811    generated_file_detection: Option<String>,
1812    minified_file_detection: Option<String>,
1813    vendor_directory_detection: Option<String>,
1814    include_lockfiles: Option<String>,
1815    binary_file_behavior: Option<BinaryFileBehavior>,
1816    output_dir: Option<String>,
1817    report_title: Option<String>,
1818    report_header_footer: Option<String>,
1819    include_globs: Option<String>,
1820    exclude_globs: Option<String>,
1821    submodule_breakdown: Option<String>,
1822    coverage_file: Option<String>,
1823    continuation_line_policy: Option<ContinuationLinePolicy>,
1824    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1825    count_compiler_directives: Option<String>,
1826    style_col_threshold: Option<String>,
1827    style_analysis_enabled: Option<String>,
1828    style_score_threshold: Option<String>,
1829    style_lang_scope: Option<String>,
1830    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
1831    cocomo_mode: Option<String>,
1832    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
1833    complexity_alert: Option<String>,
1834    /// Whether to exclude duplicate files from displayed SLOC totals.
1835    exclude_duplicates: Option<String>,
1836}
1837
1838#[allow(clippy::struct_excessive_bools)]
1839#[derive(Debug, Serialize, Deserialize, Clone)]
1840struct ScanConfig {
1841    oxide_sloc_version: String,
1842    path: String,
1843    include_globs: String,
1844    exclude_globs: String,
1845    submodule_breakdown: bool,
1846    mixed_line_policy: String,
1847    python_docstrings_as_comments: bool,
1848    generated_file_detection: bool,
1849    minified_file_detection: bool,
1850    vendor_directory_detection: bool,
1851    include_lockfiles: bool,
1852    binary_file_behavior: String,
1853    output_dir: String,
1854    report_title: String,
1855}
1856
1857#[derive(Debug, Deserialize, Default)]
1858struct IndexQuery {
1859    path: Option<String>,
1860    include_globs: Option<String>,
1861    exclude_globs: Option<String>,
1862    submodule_breakdown: Option<String>,
1863    mixed_line_policy: Option<String>,
1864    python_docstrings_as_comments: Option<String>,
1865    generated_file_detection: Option<String>,
1866    minified_file_detection: Option<String>,
1867    vendor_directory_detection: Option<String>,
1868    include_lockfiles: Option<String>,
1869    binary_file_behavior: Option<String>,
1870    output_dir: Option<String>,
1871    report_title: Option<String>,
1872    prefilled: Option<String>,
1873    git_repo: Option<String>,
1874    git_ref: Option<String>,
1875}
1876
1877#[derive(Debug, Deserialize)]
1878struct PreviewQuery {
1879    path: Option<String>,
1880    include_globs: Option<String>,
1881    exclude_globs: Option<String>,
1882}
1883
1884#[cfg(feature = "native-dialog")]
1885#[derive(Debug, Deserialize)]
1886struct PickDirectoryQuery {
1887    kind: Option<String>,
1888    current: Option<String>,
1889}
1890
1891#[cfg(not(feature = "native-dialog"))]
1892#[derive(Debug, Deserialize)]
1893struct PickDirectoryQuery {}
1894
1895#[derive(Debug, Deserialize, Default)]
1896struct ArtifactQuery {
1897    download: Option<String>,
1898}
1899
1900#[cfg(feature = "native-dialog")]
1901#[derive(Debug, Serialize)]
1902struct PickDirectoryResponse {
1903    selected_path: Option<String>,
1904    cancelled: bool,
1905}
1906
1907#[cfg(feature = "native-dialog")]
1908async fn pick_directory_handler(
1909    State(state): State<AppState>,
1910    Query(query): Query<PickDirectoryQuery>,
1911) -> Response {
1912    if state.server_mode {
1913        return StatusCode::NOT_FOUND.into_response();
1914    }
1915    // Return immediately without opening a dialog in headless / CI environments.
1916    if std::env::var("SLOC_HEADLESS").is_ok() {
1917        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1918            .into_response();
1919    }
1920
1921    let is_coverage = query.kind.as_deref() == Some("coverage");
1922    let title = match query.kind.as_deref() {
1923        Some("output") => "Select output directory",
1924        Some("reports") => "Select folder containing saved reports",
1925        Some("coverage") => "Select LCOV coverage file",
1926        _ => "Select project directory",
1927    }
1928    .to_owned();
1929    let current = query.current.clone();
1930
1931    let picked = tokio::task::spawn_blocking(move || {
1932        // Windows: attach to the foreground thread so the dialog inherits focus,
1933        // and kick off a watcher that flashes the dialog once it appears.
1934        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1935        let fg_tid = win_dialog_focus::attach_to_foreground();
1936        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1937        win_dialog_focus::flash_dialog_when_ready(title.clone());
1938
1939        let mut dialog = rfd::FileDialog::new().set_title(&title);
1940        if let Some(current) = current.as_deref() {
1941            let resolved = resolve_input_path(current);
1942            let seed = if resolved.is_dir() {
1943                Some(resolved)
1944            } else {
1945                resolved.parent().map(Path::to_path_buf)
1946            };
1947            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1948                dialog = dialog.set_directory(seed_dir);
1949            }
1950        }
1951        let result = if is_coverage {
1952            dialog
1953                .add_filter(
1954                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1955                    &["info", "lcov", "xml"],
1956                )
1957                .pick_file()
1958        } else {
1959            dialog.pick_folder()
1960        };
1961
1962        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1963        win_dialog_focus::detach_from_foreground(fg_tid);
1964
1965        result
1966    })
1967    .await
1968    .unwrap_or(None);
1969
1970    Json(PickDirectoryResponse {
1971        selected_path: picked.as_ref().map(|p| display_path(p)),
1972        cancelled: picked.is_none(),
1973    })
1974    .into_response()
1975}
1976
1977#[cfg(not(feature = "native-dialog"))]
1978async fn pick_directory_handler(
1979    State(_state): State<AppState>,
1980    Query(_query): Query<PickDirectoryQuery>,
1981) -> Response {
1982    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1983}
1984
1985#[cfg(feature = "native-dialog")]
1986async fn pick_file_handler(State(state): State<AppState>) -> Response {
1987    if state.server_mode {
1988        return StatusCode::NOT_FOUND.into_response();
1989    }
1990    if std::env::var("SLOC_HEADLESS").is_ok() {
1991        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1992            .into_response();
1993    }
1994    let picked = tokio::task::spawn_blocking(|| {
1995        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1996        let fg_tid = win_dialog_focus::attach_to_foreground();
1997        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1998        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1999
2000        let result = rfd::FileDialog::new()
2001            .set_title("Select HTML report")
2002            .add_filter("HTML report", &["html"])
2003            .pick_file();
2004
2005        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2006        win_dialog_focus::detach_from_foreground(fg_tid);
2007
2008        result
2009    })
2010    .await
2011    .unwrap_or(None);
2012    Json(PickDirectoryResponse {
2013        selected_path: picked.as_ref().map(|p| display_path(p)),
2014        cancelled: picked.is_none(),
2015    })
2016    .into_response()
2017}
2018
2019#[cfg(not(feature = "native-dialog"))]
2020async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2021    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2022}
2023
2024// ── Browser-upload handlers (server mode only) ────────────────────────────────
2025
2026/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2027/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2028fn is_upload_tmp_path(path: &Path) -> bool {
2029    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2030    path.starts_with(&upload_root)
2031}
2032
2033/// Returns true when `path` is the built-in sample or test-fixture directory.
2034/// These paths ship with the server binary and are always safe to scan/preview.
2035fn is_sample_path(path: &Path) -> bool {
2036    let root = workspace_root();
2037    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2038}
2039
2040/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2041fn upload_base_dir() -> PathBuf {
2042    std::env::temp_dir().join("oxide-sloc-uploads")
2043}
2044
2045/// Returns the staging path for a given upload id inside the base dir.
2046fn upload_staging_path(id: &str) -> PathBuf {
2047    upload_base_dir().join(id)
2048}
2049
2050/// Validate basic field constraints on a directory-upload request.
2051/// Returns an error `Response` if the request should be rejected immediately.
2052#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2053fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2054    const MAX_FILES: usize = 50_000;
2055    if body.files.is_empty() {
2056        return Err((
2057            StatusCode::BAD_REQUEST,
2058            Json(serde_json::json!({"error": "No files received"})),
2059        )
2060            .into_response());
2061    }
2062    if body.files.len() > MAX_FILES {
2063        return Err((
2064            StatusCode::PAYLOAD_TOO_LARGE,
2065            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2066        )
2067            .into_response());
2068    }
2069    Ok(())
2070}
2071
2072/// Resolve or create the staging directory for a directory upload.
2073/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2074fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2075    match id {
2076        Some(id)
2077            if !id.is_empty()
2078                && id.len() <= 36
2079                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2080        {
2081            (id.to_string(), upload_staging_path(id))
2082        }
2083        _ => {
2084            let new_id = uuid::Uuid::new_v4().to_string();
2085            let staging = upload_staging_path(&new_id);
2086            (new_id, staging)
2087        }
2088    }
2089}
2090
2091/// Decode, size-check, and write one uploaded file entry into `staging`.
2092/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2093/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2094/// cleaning up `staging` before propagating the error.
2095#[allow(clippy::result_large_err)]
2096async fn stage_decoded_entry(
2097    entry: &UploadedFile,
2098    staging: &Path,
2099    total_bytes: &mut usize,
2100    project_root: &mut Option<PathBuf>,
2101) -> Result<(), Response> {
2102    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2103
2104    let Ok(data) = base64::Engine::decode(
2105        &base64::engine::general_purpose::STANDARD,
2106        entry.content.as_bytes(),
2107    ) else {
2108        return Ok(());
2109    };
2110
2111    *total_bytes += data.len();
2112    if *total_bytes > MAX_TOTAL_BYTES {
2113        return Err((
2114            StatusCode::PAYLOAD_TOO_LARGE,
2115            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2116        )
2117            .into_response());
2118    }
2119
2120    let rel = std::path::Path::new(&entry.path);
2121    if project_root.is_none() {
2122        if let Some(first) = rel.components().next() {
2123            *project_root = Some(staging.join(first.as_os_str()));
2124        }
2125    }
2126
2127    let dest = staging.join(rel);
2128    if let Some(parent) = dest.parent() {
2129        if tokio::fs::create_dir_all(parent).await.is_err() {
2130            return Err((
2131                StatusCode::INTERNAL_SERVER_ERROR,
2132                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2133            )
2134                .into_response());
2135        }
2136    }
2137
2138    if tokio::fs::write(&dest, &data).await.is_err() {
2139        return Err((
2140            StatusCode::INTERNAL_SERVER_ERROR,
2141            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2142        )
2143            .into_response());
2144    }
2145
2146    Ok(())
2147}
2148
2149/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2150/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2151/// an error `Response` on failure (staging dir is cleaned up before returning).
2152async fn write_upload_files(
2153    files: &[UploadedFile],
2154    staging: &Path,
2155    upload_id: &str,
2156) -> Result<(usize, Option<PathBuf>), Response> {
2157    let mut total_bytes: usize = 0;
2158    let mut project_root: Option<PathBuf> = None;
2159    let mut traversal_attempts: usize = 0;
2160
2161    for entry in files {
2162        let rel = std::path::Path::new(&entry.path);
2163        if rel
2164            .components()
2165            .any(|c| matches!(c, std::path::Component::ParentDir))
2166        {
2167            traversal_attempts += 1;
2168            if traversal_attempts >= 5 {
2169                let _ = tokio::fs::remove_dir_all(staging).await;
2170                tracing::warn!(
2171                    event = "upload_path_traversal",
2172                    upload_id = %upload_id,
2173                    "Upload rejected: repeated path traversal attempts detected"
2174                );
2175                return Err((
2176                    StatusCode::BAD_REQUEST,
2177                    Json(serde_json::json!({"error": "Upload rejected"})),
2178                )
2179                    .into_response());
2180            }
2181            continue;
2182        }
2183
2184        if let Err(resp) =
2185            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2186        {
2187            let _ = tokio::fs::remove_dir_all(staging).await;
2188            return Err(resp);
2189        }
2190    }
2191
2192    Ok((files.len(), project_root))
2193}
2194
2195/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2196/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2197fn parse_tarball_size_caps() -> (u64, u64) {
2198    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2199        .ok()
2200        .and_then(|v| v.parse().ok())
2201        .unwrap_or(2048_u64)
2202        * 1024
2203        * 1024;
2204    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2205        .ok()
2206        .and_then(|v| v.parse().ok())
2207        .unwrap_or(10_240_u64)
2208        * 1024
2209        * 1024;
2210    (compressed, decompressed)
2211}
2212
2213/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2214/// Returns the number of compressed bytes written, or an error `Response`.
2215/// Cleans up `dest_path` on error.
2216#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2217async fn stream_body_to_file(
2218    body: axum::body::Body,
2219    dest_path: &Path,
2220    max_bytes: u64,
2221) -> Result<u64, Response> {
2222    use http_body_util::BodyExt as _;
2223    use tokio::io::AsyncWriteExt as _;
2224
2225    let mut file = match tokio::fs::File::create(dest_path).await {
2226        Ok(f) => f,
2227        Err(e) => {
2228            tracing::error!(
2229                event = "upload_io_error",
2230                "failed to create tarball temp file: {e}"
2231            );
2232            return Err((
2233                StatusCode::INTERNAL_SERVER_ERROR,
2234                Json(serde_json::json!({"error": "Upload initialization failed"})),
2235            )
2236                .into_response());
2237        }
2238    };
2239
2240    let mut body = body;
2241    let mut written: u64 = 0;
2242    loop {
2243        match body.frame().await {
2244            None => break,
2245            Some(Err(e)) => {
2246                let _ = tokio::fs::remove_file(dest_path).await;
2247                return Err((
2248                    StatusCode::BAD_REQUEST,
2249                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2250                )
2251                    .into_response());
2252            }
2253            Some(Ok(frame)) => {
2254                if let Ok(data) = frame.into_data() {
2255                    written += data.len() as u64;
2256                    if written > max_bytes {
2257                        let _ = tokio::fs::remove_file(dest_path).await;
2258                        return Err((
2259                            StatusCode::PAYLOAD_TOO_LARGE,
2260                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2261                        )
2262                            .into_response());
2263                    }
2264                    if let Err(e) = file.write_all(&data).await {
2265                        let _ = tokio::fs::remove_file(dest_path).await;
2266                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2267                        return Err((
2268                            StatusCode::INTERNAL_SERVER_ERROR,
2269                            Json(serde_json::json!({"error": "Upload write failed"})),
2270                        )
2271                            .into_response());
2272                    }
2273                }
2274            }
2275        }
2276    }
2277    drop(file);
2278    Ok(written)
2279}
2280
2281/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2282/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2283/// on failure (staging dir is cleaned up before returning).
2284#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2285async fn extract_tarball_to_staging(
2286    tarball_path: &Path,
2287    staging: &Path,
2288    max_decompressed_bytes: u64,
2289) -> Result<(), Response> {
2290    let staging_clone = staging.to_path_buf();
2291    let tarball_clone = tarball_path.to_path_buf();
2292    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2293        let file = std::fs::File::open(&tarball_clone)?;
2294        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2295        let limited = SizeLimitReader {
2296            inner: gz,
2297            remaining: max_decompressed_bytes,
2298        };
2299        let mut archive = tar::Archive::new(limited);
2300        archive.set_overwrite(true);
2301        archive.set_preserve_permissions(false);
2302        std::fs::create_dir_all(&staging_clone)?;
2303        archive.unpack(&staging_clone)?;
2304        Ok(())
2305    })
2306    .await;
2307    let _ = tokio::fs::remove_file(tarball_path).await;
2308
2309    match extract_result {
2310        Ok(Ok(())) => Ok(()),
2311        Ok(Err(e)) => {
2312            let _ = tokio::fs::remove_dir_all(staging).await;
2313            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2314            tracing::warn!(
2315                event = "upload_extract_error",
2316                "tarball extraction failed: {e:#}"
2317            );
2318            let (status, msg) = if is_size_limit {
2319                (
2320                    StatusCode::PAYLOAD_TOO_LARGE,
2321                    "Archive exceeds the decompressed size limit",
2322                )
2323            } else {
2324                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2325            };
2326            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2327        }
2328        Err(e) => {
2329            let _ = tokio::fs::remove_dir_all(staging).await;
2330            tracing::error!(
2331                event = "upload_extract_panic",
2332                "tarball extraction task panicked: {e}"
2333            );
2334            Err((
2335                StatusCode::INTERNAL_SERVER_ERROR,
2336                Json(serde_json::json!({"error": "Archive extraction failed"})),
2337            )
2338                .into_response())
2339        }
2340    }
2341}
2342
2343/// If `staging` contains exactly one top-level directory, return its path
2344/// (the common case when the archive was created with `webkitRelativePath`).
2345/// Otherwise return `None`.
2346async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2347    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2348    let first = entries.next_entry().await.ok()??;
2349    if !first.path().is_dir() {
2350        return None;
2351    }
2352    if entries.next_entry().await.unwrap_or(None).is_some() {
2353        return None;
2354    }
2355    Some(first.path())
2356}
2357
2358/// Request body for `POST /api/upload-directory`.
2359///
2360/// Each entry carries a relative path (identical to the browser's
2361/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2362/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2363/// avoids pulling in a `multipart` library that is not in the vendor archive.
2364#[derive(Deserialize)]
2365struct UploadDirRequest {
2366    files: Vec<UploadedFile>,
2367    /// If provided, append this batch to an existing upload session instead of
2368    /// creating a new staging directory. Must be a plain UUID (no path separators).
2369    upload_id: Option<String>,
2370}
2371
2372#[derive(Deserialize)]
2373struct UploadedFile {
2374    /// `webkitRelativePath` value from the browser File object.
2375    path: String,
2376    /// Raw file bytes encoded as standard base64.
2377    content: String,
2378}
2379
2380/// POST /api/upload-directory
2381///
2382/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2383/// Saves all files to a temp staging directory preserving their relative paths,
2384/// then returns the server-side root directory path so the caller can populate
2385/// the scan-path field and run a normal analysis.
2386///
2387/// Only available in server mode; returns 404 in local mode (use the native
2388/// rfd dialog instead).
2389async fn upload_directory_handler(
2390    State(state): State<AppState>,
2391    Json(body): Json<UploadDirRequest>,
2392) -> Response {
2393    if !state.server_mode {
2394        return StatusCode::NOT_FOUND.into_response();
2395    }
2396    if let Err(resp) = validate_upload_dir_request(&body) {
2397        return resp;
2398    }
2399    // Reuse an existing staging dir when the client sends a continuation batch,
2400    // otherwise create a fresh one. Validate the id to prevent path traversal.
2401    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2402    match write_upload_files(&body.files, &staging, &upload_id).await {
2403        Ok((file_count, project_root)) => {
2404            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2405            Json(serde_json::json!({
2406                "tmp_path": scan_root.to_string_lossy(),
2407                "file_count": file_count,
2408                "upload_id": upload_id.clone()
2409            }))
2410            .into_response()
2411        }
2412        Err(resp) => resp,
2413    }
2414}
2415
2416/// Request body for `POST /api/upload-file`.
2417#[derive(Deserialize)]
2418struct UploadFileRequest {
2419    /// Original filename (used only to preserve the extension).
2420    filename: String,
2421    /// File bytes encoded as standard base64.
2422    content: String,
2423}
2424
2425/// POST /api/upload-file
2426///
2427/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2428/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2429/// Only available in server mode.
2430async fn upload_file_handler(
2431    State(state): State<AppState>,
2432    Json(body): Json<UploadFileRequest>,
2433) -> Response {
2434    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2435
2436    if !state.server_mode {
2437        return StatusCode::NOT_FOUND.into_response();
2438    }
2439
2440    let Ok(data) = base64::Engine::decode(
2441        &base64::engine::general_purpose::STANDARD,
2442        body.content.as_bytes(),
2443    ) else {
2444        return (
2445            StatusCode::BAD_REQUEST,
2446            Json(serde_json::json!({"error": "Invalid base64 content"})),
2447        )
2448            .into_response();
2449    };
2450
2451    if data.len() > MAX_FILE_BYTES {
2452        return (
2453            StatusCode::PAYLOAD_TOO_LARGE,
2454            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2455        )
2456            .into_response();
2457    }
2458
2459    // Sanitise: strip any directory component from the filename.
2460    let filename = std::path::Path::new(&body.filename)
2461        .file_name()
2462        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2463
2464    let upload_id = uuid::Uuid::new_v4();
2465    let staging = std::env::temp_dir()
2466        .join("oxide-sloc-uploads")
2467        .join(upload_id.to_string());
2468
2469    if tokio::fs::create_dir_all(&staging).await.is_err() {
2470        return (
2471            StatusCode::INTERNAL_SERVER_ERROR,
2472            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2473        )
2474            .into_response();
2475    }
2476
2477    let dest = staging.join(&filename);
2478    if tokio::fs::write(&dest, &data).await.is_err() {
2479        let _ = tokio::fs::remove_dir_all(&staging).await;
2480        return (
2481            StatusCode::INTERNAL_SERVER_ERROR,
2482            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2483        )
2484            .into_response();
2485    }
2486
2487    Json(serde_json::json!({
2488        "tmp_path": dest.to_string_lossy(),
2489        "upload_id": upload_id.to_string()
2490    }))
2491    .into_response()
2492}
2493
2494/// POST /api/upload-tarball
2495///
2496/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2497/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2498/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2499/// project root. The two size fields power the "Original / Compressed project size" display in the
2500/// web UI.
2501///
2502/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
2503/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
2504/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
2505/// bounded regardless of project size.
2506/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2507/// decompressed. Wraps any `std::io::Read` source.
2508struct SizeLimitReader<R> {
2509    inner: R,
2510    remaining: u64,
2511}
2512impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2513    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2514        if self.remaining == 0 {
2515            return Err(std::io::Error::other("decompressed size limit exceeded"));
2516        }
2517        let n = self.inner.read(buf)?;
2518        self.remaining = self.remaining.saturating_sub(n as u64);
2519        Ok(n)
2520    }
2521}
2522
2523async fn upload_tarball_handler(
2524    State(state): State<AppState>,
2525    request: axum::extract::Request,
2526) -> Response {
2527    if !state.server_mode {
2528        return StatusCode::NOT_FOUND.into_response();
2529    }
2530
2531    let upload_id = uuid::Uuid::new_v4().to_string();
2532    let upload_base = upload_base_dir();
2533    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2534    let staging = upload_staging_path(&upload_id);
2535    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2536
2537    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2538        tracing::error!(
2539            event = "upload_io_error",
2540            "failed to create upload base dir: {e}"
2541        );
2542        return (
2543            StatusCode::INTERNAL_SERVER_ERROR,
2544            Json(serde_json::json!({"error": "Upload initialization failed"})),
2545        )
2546            .into_response();
2547    }
2548
2549    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2550    let compressed_bytes =
2551        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2552            Ok(n) => n,
2553            Err(resp) => return resp,
2554        };
2555
2556    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2557    if let Err(resp) =
2558        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2559    {
2560        return resp;
2561    }
2562
2563    // ── 3. Find the project root inside the staging dir ───────────────────────
2564    // If the tar contained a single top-level directory (the common case when the
2565    // browser uses `webkitRelativePath`), return that as the scan root so the path
2566    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2567    let scan_root = find_single_top_dir(&staging)
2568        .await
2569        .unwrap_or_else(|| staging.clone());
2570
2571    // Compute original (uncompressed) size of the extracted tree.
2572    let original_bytes = tokio::task::spawn_blocking({
2573        let p = scan_root.clone();
2574        move || dir_size_bytes(&p)
2575    })
2576    .await
2577    .unwrap_or(0);
2578
2579    Json(serde_json::json!({
2580        "tmp_path": scan_root.to_string_lossy(),
2581        "upload_id": upload_id,
2582        "compressed_bytes": compressed_bytes,
2583        "original_bytes": original_bytes,
2584    }))
2585    .into_response()
2586}
2587
2588#[derive(Deserialize)]
2589struct LocateReportForm {
2590    file_path: String,
2591    #[serde(default)]
2592    redirect_url: Option<String>,
2593    #[serde(default)]
2594    expected_run_id: Option<String>,
2595}
2596
2597/// Render a view-reports error page and return it as a `Response`.
2598fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2599    let html = ErrorTemplate {
2600        message: message.into(),
2601        last_report_url: Some("/view-reports".to_string()),
2602        last_report_label: Some("View Reports".to_string()),
2603        run_id: None,
2604        error_code: None,
2605        csp_nonce: csp_nonce.to_owned(),
2606        version: env!("CARGO_PKG_VERSION"),
2607    }
2608    .render()
2609    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2610    Html(html).into_response()
2611}
2612
2613/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
2614fn registry_entry_from_run(
2615    run: &AnalysisRun,
2616    json_path: PathBuf,
2617    html_path: PathBuf,
2618) -> RegistryEntry {
2619    let project_label = run.input_roots.first().map_or_else(
2620        || "Unknown Project".to_string(),
2621        |r| sanitize_project_label(r),
2622    );
2623    RegistryEntry {
2624        run_id: run.tool.run_id.clone(),
2625        timestamp_utc: run.tool.timestamp_utc,
2626        project_label,
2627        input_roots: run.input_roots.clone(),
2628        json_path: Some(json_path),
2629        html_path: Some(html_path),
2630        pdf_path: None,
2631        summary: ScanSummarySnapshot {
2632            files_analyzed: run.summary_totals.files_analyzed,
2633            files_skipped: run.summary_totals.files_skipped,
2634            total_physical_lines: run.summary_totals.total_physical_lines,
2635            code_lines: run.summary_totals.code_lines,
2636            comment_lines: run.summary_totals.comment_lines,
2637            blank_lines: run.summary_totals.blank_lines,
2638            functions: run.summary_totals.functions,
2639            classes: run.summary_totals.classes,
2640            variables: run.summary_totals.variables,
2641            imports: run.summary_totals.imports,
2642            test_count: run.summary_totals.test_count,
2643            coverage_lines_found: run.summary_totals.coverage_lines_found,
2644            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2645            coverage_functions_found: run.summary_totals.coverage_functions_found,
2646            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2647            coverage_branches_found: run.summary_totals.coverage_branches_found,
2648            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2649        },
2650        csv_path: None,
2651        xlsx_path: None,
2652        git_branch: None,
2653        git_commit: None,
2654        git_author: None,
2655        git_tags: None,
2656        git_nearest_tag: None,
2657        git_commit_date: None,
2658    }
2659}
2660
2661/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
2662/// immediately without requiring a server restart.
2663pub(crate) async fn register_artifacts_in_registry(
2664    state: &AppState,
2665    label: &str,
2666    run: &AnalysisRun,
2667    artifacts: &RunArtifacts,
2668) {
2669    let Some(json_path) = artifacts.json_path.clone() else {
2670        return;
2671    };
2672    let Some(html_path) = artifacts.html_path.clone() else {
2673        return;
2674    };
2675    let mut entry = registry_entry_from_run(run, json_path, html_path);
2676    entry.project_label = label.to_owned();
2677    let mut reg = state.registry.lock().await;
2678    reg.add_entry(entry);
2679    let _ = reg.save(&state.registry_path);
2680}
2681
2682fn is_html_report_file(p: &Path) -> bool {
2683    p.is_file()
2684        && p.extension()
2685            .and_then(|x| x.to_str())
2686            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2687        && p.file_name()
2688            .and_then(|n| n.to_str())
2689            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2690}
2691
2692fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2693    fs::read_dir(dir)
2694        .ok()?
2695        .flatten()
2696        .map(|e| e.path())
2697        .find(|p| is_html_report_file(p))
2698}
2699
2700fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2701    if let Some(f) = find_html_report_in_dir(dir) {
2702        return Some(f);
2703    }
2704    if let Ok(rd) = fs::read_dir(dir) {
2705        for entry in rd.flatten() {
2706            let sub = entry.path();
2707            if sub.is_dir() {
2708                if let Some(f) = find_html_report_in_dir(&sub) {
2709                    return Some(f);
2710                }
2711            }
2712        }
2713    }
2714    None
2715}
2716
2717/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
2718/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
2719///
2720/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
2721#[allow(clippy::result_large_err)]
2722fn validate_locate_request(
2723    state: &AppState,
2724    file_path: &str,
2725    csp_nonce: &str,
2726) -> Result<(PathBuf, PathBuf), Response> {
2727    let raw = PathBuf::from(file_path);
2728
2729    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
2730    let html_path = if raw.is_dir() {
2731        let found = find_html_report_in_tree(&raw);
2732        match found {
2733            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2734            None => {
2735                return Err(locate_report_error(
2736                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
2737                     the folder that contains your scan output (result_*.html or report_*.html).",
2738                    csp_nonce,
2739                ));
2740            }
2741        }
2742    } else {
2743        let file_ext = raw
2744            .extension()
2745            .and_then(|e| e.to_str())
2746            .unwrap_or("")
2747            .to_ascii_lowercase();
2748        if file_ext != "html" {
2749            return Err(locate_report_error(
2750                "Please select the scan output folder, or an .html report file directly.",
2751                csp_nonce,
2752            ));
2753        }
2754        match fs::canonicalize(&raw) {
2755            Ok(p) => strip_unc_prefix(p),
2756            Err(_) => {
2757                return Err(locate_report_error(
2758                    "Report file not found or path is invalid.",
2759                    csp_nonce,
2760                ));
2761            }
2762        }
2763    };
2764
2765    if state.server_mode {
2766        let output_root = resolve_output_root(None);
2767        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2768        if !html_path.starts_with(&canonical_root) {
2769            return Err(locate_report_error(
2770                "Report file must be within the configured output directory.",
2771                csp_nonce,
2772            ));
2773        }
2774    }
2775    let parent = match html_path.parent() {
2776        Some(p) => p.to_path_buf(),
2777        None => {
2778            return Err(locate_report_error(
2779                "Report file has no parent directory.",
2780                csp_nonce,
2781            ));
2782        }
2783    };
2784    Ok((html_path, parent))
2785}
2786
2787/// JSON-or-HTML error for `locate_report_handler` error paths.
2788fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2789    if want_json {
2790        (
2791            StatusCode::UNPROCESSABLE_ENTITY,
2792            axum::Json(serde_json::json!({"ok": false, "message": msg})),
2793        )
2794            .into_response()
2795    } else {
2796        locate_report_error(msg, csp_nonce)
2797    }
2798}
2799
2800/// JSON-or-redirect success for locate/relocate handler success paths.
2801fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2802    if want_json {
2803        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2804    } else {
2805        axum::response::Redirect::to(redirect).into_response()
2806    }
2807}
2808
2809/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
2810/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
2811fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2812    for jpath in candidates {
2813        if let Ok(run) = read_json(jpath) {
2814            if expected.is_empty() || run.tool.run_id == expected {
2815                return Some((jpath.clone(), run.tool.run_id));
2816            }
2817        }
2818    }
2819    None
2820}
2821
2822fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2823    html_path
2824        .parent()
2825        .and_then(|p| p.parent())
2826        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2827}
2828
2829fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2830    let mut hits = collect_result_json_candidates(scan_root);
2831    if hits.is_empty() {
2832        hits = collect_result_json_candidates(parent);
2833    }
2834    hits.sort();
2835    hits
2836}
2837
2838#[allow(clippy::too_many_lines)]
2839async fn locate_report_handler(
2840    State(state): State<AppState>,
2841    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2842    headers: axum::http::HeaderMap,
2843    Form(form): Form<LocateReportForm>,
2844) -> impl IntoResponse {
2845    let want_json = headers
2846        .get(axum::http::header::ACCEPT)
2847        .and_then(|v| v.to_str().ok())
2848        .is_some_and(|v| v.contains("application/json"));
2849
2850    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2851        Ok(v) => v,
2852        Err(resp) => {
2853            if want_json {
2854                return locate_handler_err(
2855                    true,
2856                    "No HTML report file found in the selected folder. \
2857                     Make sure you selected the folder that contains your \
2858                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
2859                        .to_string(),
2860                    &csp_nonce,
2861                );
2862            }
2863            return resp;
2864        }
2865    };
2866
2867    // Search for result_*.json in the HTML's parent and also its grandparent (handles
2868    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
2869    let scan_root_owned = resolve_scan_root(&html_path, &parent);
2870    let scan_root: &Path = &scan_root_owned;
2871    let json_candidates = gather_json_candidates(scan_root, &parent);
2872
2873    // If the expected_run_id was provided, find a JSON that matches it exactly.
2874    let expected_run_id = form
2875        .expected_run_id
2876        .as_deref()
2877        .unwrap_or("")
2878        .trim()
2879        .to_string();
2880
2881    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2882
2883    // If we have candidates but none matched the expected run_id, surface a clear error.
2884    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2885        let actual = json_candidates
2886            .iter()
2887            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2888            .unwrap_or_else(|| "unknown".to_string());
2889        return locate_handler_err(
2890            want_json,
2891            format!(
2892                "This folder contains a different scan.\n\n\
2893                 Expected run ID : {expected_run_id}\n\
2894                 Found run ID    : {actual}\n\n\
2895                 Please select the folder that contains the correct scan output."
2896            ),
2897            &csp_nonce,
2898        );
2899    }
2900
2901    let safe_redirect = form
2902        .redirect_url
2903        .as_deref()
2904        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2905        .unwrap_or("/view-reports?linked=1")
2906        .to_string();
2907
2908    let mut reg = state.registry.lock().await;
2909
2910    if let Some((json_path, run_id)) = matched_json {
2911        // Match by run_id in the registry (works even after files are moved).
2912        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2913            entry.html_path = Some(html_path);
2914            entry.json_path = Some(json_path);
2915            let _ = reg.save(&state.registry_path);
2916            drop(reg);
2917            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
2918            state.artifacts.lock().await.remove(&run_id);
2919            return redirect_or_json_ok(want_json, &safe_redirect);
2920        }
2921        // No existing entry — build one from the JSON.
2922        match read_json(&json_path) {
2923            Ok(run) => {
2924                let entry = registry_entry_from_run(&run, json_path, html_path);
2925                reg.add_entry(entry);
2926                let _ = reg.save(&state.registry_path);
2927                drop(reg);
2928                state.artifacts.lock().await.remove(&run_id);
2929                return redirect_or_json_ok(want_json, &safe_redirect);
2930            }
2931            Err(e) => {
2932                drop(reg);
2933                return locate_handler_err(
2934                    want_json,
2935                    format!(
2936                        "Found the scan folder but could not parse the result JSON.\n\n\
2937                         The file may have been saved by an older version of OxideSLOC. \
2938                         Re-running the analysis will create a fresh, compatible record.\n\n\
2939                         Error: {e}"
2940                    ),
2941                    &csp_nonce,
2942                );
2943            }
2944        }
2945    }
2946
2947    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
2948    if let Some(entry) = reg
2949        .entries
2950        .iter_mut()
2951        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2952    {
2953        entry.html_path = Some(html_path.clone());
2954        let _ = reg.save(&state.registry_path);
2955        drop(reg);
2956        state.artifacts.lock().await.remove(&expected_run_id);
2957        return redirect_or_json_ok(want_json, &safe_redirect);
2958    }
2959
2960    drop(reg);
2961    let hint = if state.server_mode {
2962        String::new()
2963    } else {
2964        format!(
2965            "\n\nSearched folder : {}\nHTML found      : {}",
2966            scan_root.display(),
2967            html_path.display()
2968        )
2969    };
2970    locate_handler_err(
2971        want_json,
2972        format!(
2973            "Could not link this report.\n\n\
2974             No result_*.json was found in the selected folder. \
2975             Make sure you selected the top-level scan output folder \
2976             (the one that contains html/, json/, pdf/ subfolders).{hint}"
2977        ),
2978        &csp_nonce,
2979    )
2980}
2981
2982/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
2983fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2984    fs::read_dir(dir)
2985        .ok()?
2986        .flatten()
2987        .map(|e| e.path())
2988        .find(|p| {
2989            p.is_file()
2990                && p.file_stem()
2991                    .and_then(|n| n.to_str())
2992                    .is_some_and(|n| n.starts_with("result"))
2993                && p.extension()
2994                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2995        })
2996}
2997
2998#[derive(Deserialize)]
2999struct LocateReportsDirForm {
3000    folder_path: String,
3001}
3002
3003#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3004async fn locate_reports_dir_handler(
3005    State(state): State<AppState>,
3006    Form(form): Form<LocateReportsDirForm>,
3007) -> impl IntoResponse {
3008    if state.server_mode {
3009        return StatusCode::NOT_FOUND.into_response();
3010    }
3011    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3012        Ok(p) => strip_unc_prefix(p),
3013        Err(_) => {
3014            return axum::response::Redirect::to(
3015                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3016            )
3017            .into_response();
3018        }
3019    };
3020    if !folder.is_dir() {
3021        return axum::response::Redirect::to(
3022            "/view-reports?error=Selected+path+is+not+a+directory.",
3023        )
3024        .into_response();
3025    }
3026
3027    let candidates = collect_result_json_candidates(&folder);
3028
3029    if candidates.is_empty() {
3030        return axum::response::Redirect::to(
3031            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3032        )
3033        .into_response();
3034    }
3035
3036    let mut linked_count: usize = 0;
3037    let mut reg = state.registry.lock().await;
3038    for json_path in candidates {
3039        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3040            continue;
3041        };
3042        if is_dir_already_registered(&reg, &parent) {
3043            continue;
3044        }
3045        let Some(entry) = build_registry_entry_from_json(json_path) else {
3046            continue;
3047        };
3048        reg.add_entry(entry);
3049        linked_count += 1;
3050    }
3051    let _ = reg.save(&state.registry_path);
3052    drop(reg);
3053
3054    if linked_count == 0 {
3055        return axum::response::Redirect::to(
3056            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3057        )
3058        .into_response();
3059    }
3060    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3061}
3062
3063#[derive(Deserialize)]
3064struct RelocateScanForm {
3065    run_id: String,
3066    folder_path: String,
3067    redirect_url: String,
3068}
3069
3070/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3071/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3072fn relocate_folder_err(
3073    want_json: bool,
3074    status: StatusCode,
3075    msg: &str,
3076    run_id: &str,
3077    folder_hint: &str,
3078    redirect_url: &str,
3079    csp_nonce: &str,
3080) -> Response {
3081    if want_json {
3082        (
3083            status,
3084            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3085        )
3086            .into_response()
3087    } else {
3088        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3089    }
3090}
3091
3092#[allow(clippy::too_many_lines)]
3093async fn relocate_scan_handler(
3094    State(state): State<AppState>,
3095    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3096    headers: axum::http::HeaderMap,
3097    Form(form): Form<RelocateScanForm>,
3098) -> impl IntoResponse {
3099    let want_json = headers
3100        .get(axum::http::header::ACCEPT)
3101        .and_then(|v| v.to_str().ok())
3102        .is_some_and(|v| v.contains("application/json"));
3103    if state.server_mode {
3104        return StatusCode::NOT_FOUND.into_response();
3105    }
3106
3107    let run_id = form.run_id.trim().to_string();
3108    let redirect_url = form.redirect_url.trim().to_string();
3109
3110    let run_exists = {
3111        let reg = state.registry.lock().await;
3112        reg.find_by_run_id(&run_id).is_some()
3113    };
3114    if !run_exists {
3115        if want_json {
3116            return (
3117                StatusCode::NOT_FOUND,
3118                axum::Json(serde_json::json!({
3119                    "ok": false,
3120                    "message": format!("Run ID '{run_id}' not found in registry.")
3121                })),
3122            )
3123                .into_response();
3124        }
3125        let html = ErrorTemplate {
3126            message: format!("Run ID '{run_id}' not found in registry."),
3127            last_report_url: Some("/compare-scans".to_string()),
3128            last_report_label: Some("Compare Scans".to_string()),
3129            run_id: Some(run_id.clone()),
3130            error_code: Some(404),
3131            csp_nonce: csp_nonce.clone(),
3132            version: env!("CARGO_PKG_VERSION"),
3133        }
3134        .render()
3135        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3136        return Html(html).into_response();
3137    }
3138
3139    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3140        Ok(p) => strip_unc_prefix(p),
3141        Err(_) => {
3142            return relocate_folder_err(
3143                want_json,
3144                StatusCode::UNPROCESSABLE_ENTITY,
3145                "Folder not found or path is invalid.",
3146                &run_id,
3147                form.folder_path.trim(),
3148                &redirect_url,
3149                &csp_nonce,
3150            );
3151        }
3152    };
3153    if !folder.is_dir() {
3154        return relocate_folder_err(
3155            want_json,
3156            StatusCode::UNPROCESSABLE_ENTITY,
3157            "Selected path is not a directory.",
3158            &run_id,
3159            &folder.display().to_string(),
3160            &redirect_url,
3161            &csp_nonce,
3162        );
3163    }
3164
3165    let json_candidates = find_result_files_by_ext(&folder, "json");
3166    if json_candidates.is_empty() {
3167        let msg = format!(
3168            "No result JSON files found in the selected folder.\nSearched: {}",
3169            folder.display()
3170        );
3171        return relocate_folder_err(
3172            want_json,
3173            StatusCode::UNPROCESSABLE_ENTITY,
3174            &msg,
3175            &run_id,
3176            &folder.display().to_string(),
3177            &redirect_url,
3178            &csp_nonce,
3179        );
3180    }
3181
3182    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3183        let msg = format!(
3184            "No matching scan found in the selected folder.\n\
3185             The JSON files present do not contain run ID: {run_id}\n\
3186             Searched: {}",
3187            folder.display()
3188        );
3189        return relocate_folder_err(
3190            want_json,
3191            StatusCode::UNPROCESSABLE_ENTITY,
3192            &msg,
3193            &run_id,
3194            &folder.display().to_string(),
3195            &redirect_url,
3196            &csp_nonce,
3197        );
3198    };
3199
3200    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3201    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3202    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3203
3204    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3205        redirect_url
3206    } else {
3207        "/compare-scans".to_string()
3208    };
3209    redirect_or_json_ok(want_json, &safe_redirect)
3210}
3211
3212fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3213    let mut out = Vec::new();
3214    collect_scan_files_by_ext(folder, ext, &mut out);
3215    if let Ok(rd) = fs::read_dir(folder) {
3216        for entry in rd.flatten() {
3217            let sub = entry.path();
3218            if sub.is_dir() {
3219                collect_scan_files_by_ext(&sub, ext, &mut out);
3220            }
3221        }
3222    }
3223    out
3224}
3225
3226fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3227    let Ok(rd) = fs::read_dir(dir) else { return };
3228    for entry in rd.flatten() {
3229        let p = entry.path();
3230        if p.is_file()
3231            && p.file_stem()
3232                .and_then(|n| n.to_str())
3233                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3234            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3235        {
3236            out.push(p);
3237        }
3238    }
3239}
3240
3241fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3242    candidates
3243        .iter()
3244        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3245        .cloned()
3246}
3247
3248async fn update_run_file_paths(
3249    state: &AppState,
3250    run_id: &str,
3251    json_path: PathBuf,
3252    html_path: Option<PathBuf>,
3253    pdf_path: Option<PathBuf>,
3254) {
3255    let mut reg = state.registry.lock().await;
3256    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3257        entry.json_path = Some(json_path);
3258        if let Some(hp) = html_path {
3259            entry.html_path = Some(hp);
3260        }
3261        if let Some(pp) = pdf_path {
3262            entry.pdf_path = Some(pp);
3263        }
3264    }
3265    let _ = reg.save(&state.registry_path);
3266}
3267
3268fn missing_scan_relocate_response(
3269    message: &str,
3270    run_id: &str,
3271    folder_hint: &str,
3272    redirect_url: &str,
3273    server_mode: bool,
3274    csp_nonce: &str,
3275) -> axum::response::Response {
3276    let html = RelocateScanTemplate {
3277        message: message.to_string(),
3278        run_id: run_id.to_string(),
3279        folder_hint: folder_hint.to_string(),
3280        redirect_url: redirect_url.to_string(),
3281        server_mode,
3282        csp_nonce: csp_nonce.to_owned(),
3283        version: env!("CARGO_PKG_VERSION"),
3284    }
3285    .render()
3286    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3287    (StatusCode::NOT_FOUND, Html(html)).into_response()
3288}
3289
3290// ── Watched-directory helpers ─────────────────────────────────────────────────
3291
3292/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3293fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3294    let mut candidates = Vec::new();
3295    if let Some(j) = find_result_json_in_dir(folder) {
3296        candidates.push(j);
3297    }
3298    if let Ok(dir_entries) = fs::read_dir(folder) {
3299        for entry in dir_entries.flatten() {
3300            let sub = entry.path();
3301            if sub.is_dir() {
3302                if let Some(j) = find_result_json_in_dir(&sub) {
3303                    candidates.push(j);
3304                }
3305            }
3306        }
3307    }
3308    candidates
3309}
3310
3311fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3312    reg.entries.iter().any(|e| {
3313        let dir_match = e
3314            .json_path
3315            .as_ref()
3316            .and_then(|p| p.parent())
3317            .is_some_and(|p| p == parent)
3318            || e.html_path
3319                .as_ref()
3320                .and_then(|p| p.parent())
3321                .is_some_and(|p| p == parent);
3322        dir_match
3323            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3324                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3325    })
3326}
3327
3328fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3329    let parent = json_path.parent()?.to_path_buf();
3330    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3331        rd.flatten()
3332            .map(|e| e.path())
3333            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3334    });
3335    let run = read_json(&json_path).ok()?;
3336    let project_label = run.input_roots.first().map_or_else(
3337        || "Unknown Project".to_string(),
3338        |r| sanitize_project_label(r),
3339    );
3340    Some(RegistryEntry {
3341        run_id: run.tool.run_id.clone(),
3342        timestamp_utc: run.tool.timestamp_utc,
3343        project_label,
3344        input_roots: run.input_roots.clone(),
3345        json_path: Some(json_path),
3346        html_path,
3347        pdf_path: None,
3348        csv_path: None,
3349        xlsx_path: None,
3350        summary: ScanSummarySnapshot {
3351            files_analyzed: run.summary_totals.files_analyzed,
3352            files_skipped: run.summary_totals.files_skipped,
3353            total_physical_lines: run.summary_totals.total_physical_lines,
3354            code_lines: run.summary_totals.code_lines,
3355            comment_lines: run.summary_totals.comment_lines,
3356            blank_lines: run.summary_totals.blank_lines,
3357            functions: run.summary_totals.functions,
3358            classes: run.summary_totals.classes,
3359            variables: run.summary_totals.variables,
3360            imports: run.summary_totals.imports,
3361            test_count: run.summary_totals.test_count,
3362            coverage_lines_found: run.summary_totals.coverage_lines_found,
3363            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3364            coverage_functions_found: run.summary_totals.coverage_functions_found,
3365            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3366            coverage_branches_found: run.summary_totals.coverage_branches_found,
3367            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3368        },
3369        git_branch: run.git_branch.clone(),
3370        git_commit: run.git_commit_short.clone(),
3371        git_author: run.git_commit_author.clone(),
3372        git_tags: run.git_tags.clone(),
3373        git_nearest_tag: run.git_nearest_tag.clone(),
3374        git_commit_date: run.git_commit_date,
3375    })
3376}
3377
3378/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3379/// Returns the number of newly linked entries.
3380fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3381    let mut linked = 0usize;
3382    for json_path in collect_result_json_candidates(folder) {
3383        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3384            continue;
3385        };
3386        if is_dir_already_registered(reg, &parent) {
3387            continue;
3388        }
3389        let Some(entry) = build_registry_entry_from_json(json_path) else {
3390            continue;
3391        };
3392        reg.add_entry(entry);
3393        linked += 1;
3394    }
3395    linked
3396}
3397
3398/// Scan all watched directories (plus the default output root) into `reg`.
3399async fn auto_scan_watched_dirs(state: &AppState) {
3400    let dirs: Vec<PathBuf> = {
3401        let wd = state.watched_dirs.lock().await;
3402        wd.dirs.clone()
3403    };
3404    if dirs.is_empty() {
3405        return;
3406    }
3407    let mut reg = state.registry.lock().await;
3408    let mut total = 0usize;
3409    for dir in &dirs {
3410        if dir.is_dir() {
3411            total += scan_folder_into_registry(dir, &mut reg);
3412        }
3413    }
3414    if total > 0 {
3415        let _ = reg.save(&state.registry_path);
3416    }
3417}
3418
3419// ── Watched-dir route forms ───────────────────────────────────────────────────
3420
3421#[derive(Deserialize)]
3422struct WatchedDirForm {
3423    folder_path: String,
3424    #[serde(default = "default_redirect")]
3425    redirect_to: String,
3426}
3427
3428fn default_redirect() -> String {
3429    "/view-reports".to_string()
3430}
3431
3432#[derive(Deserialize)]
3433struct WatchedDirRefreshForm {
3434    #[serde(default = "default_redirect")]
3435    redirect_to: String,
3436}
3437
3438// ── Watched-dir helpers ───────────────────────────────────────────────────────
3439
3440/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3441fn safe_redirect(dest: &str) -> &str {
3442    if dest.starts_with('/') {
3443        dest
3444    } else {
3445        "/"
3446    }
3447}
3448
3449// ── Watched-dir handlers ──────────────────────────────────────────────────────
3450
3451async fn add_watched_dir_handler(
3452    State(state): State<AppState>,
3453    Form(form): Form<WatchedDirForm>,
3454) -> impl IntoResponse {
3455    if state.server_mode {
3456        return StatusCode::NOT_FOUND.into_response();
3457    }
3458    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3459        strip_unc_prefix(p)
3460    } else {
3461        let dest = format!(
3462            "{}?error=Folder+not+found+or+path+is+invalid.",
3463            safe_redirect(&form.redirect_to)
3464        );
3465        return axum::response::Redirect::to(&dest).into_response();
3466    };
3467    if !folder.is_dir() {
3468        let dest = format!(
3469            "{}?error=Selected+path+is+not+a+directory.",
3470            safe_redirect(&form.redirect_to)
3471        );
3472        return axum::response::Redirect::to(&dest).into_response();
3473    }
3474
3475    // Persist the watched directory.
3476    {
3477        let mut wd = state.watched_dirs.lock().await;
3478        wd.add(folder.clone());
3479        let _ = wd.save(&state.watched_dirs_path);
3480    }
3481
3482    // Immediately scan the folder and add any new reports.
3483    let linked = {
3484        let mut reg = state.registry.lock().await;
3485        let n = scan_folder_into_registry(&folder, &mut reg);
3486        if n > 0 {
3487            let _ = reg.save(&state.registry_path);
3488        }
3489        n
3490    };
3491
3492    let dest = if linked > 0 {
3493        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3494    } else {
3495        format!(
3496            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3497            safe_redirect(&form.redirect_to)
3498        )
3499    };
3500    axum::response::Redirect::to(&dest).into_response()
3501}
3502
3503async fn remove_watched_dir_handler(
3504    State(state): State<AppState>,
3505    Form(form): Form<WatchedDirForm>,
3506) -> impl IntoResponse {
3507    if state.server_mode {
3508        return StatusCode::NOT_FOUND.into_response();
3509    }
3510    let folder = PathBuf::from(&form.folder_path);
3511    {
3512        let mut wd = state.watched_dirs.lock().await;
3513        wd.remove(&folder);
3514        let _ = wd.save(&state.watched_dirs_path);
3515    }
3516    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3517}
3518
3519async fn refresh_watched_dirs_handler(
3520    State(state): State<AppState>,
3521    Form(form): Form<WatchedDirRefreshForm>,
3522) -> impl IntoResponse {
3523    if state.server_mode {
3524        return StatusCode::NOT_FOUND.into_response();
3525    }
3526    let dirs: Vec<PathBuf> = {
3527        let wd = state.watched_dirs.lock().await;
3528        wd.dirs.clone()
3529    };
3530    let mut total = 0usize;
3531    {
3532        let mut reg = state.registry.lock().await;
3533        for dir in &dirs {
3534            if dir.is_dir() {
3535                total += scan_folder_into_registry(dir, &mut reg);
3536            }
3537        }
3538        if total > 0 {
3539            let _ = reg.save(&state.registry_path);
3540        }
3541    }
3542    let dest = if total > 0 {
3543        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3544    } else {
3545        safe_redirect(&form.redirect_to).to_owned()
3546    };
3547    axum::response::Redirect::to(&dest).into_response()
3548}
3549
3550#[derive(Debug, Deserialize)]
3551struct OpenPathQuery {
3552    path: Option<String>,
3553}
3554
3555fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3556    let mut ancestor = std::path::Path::new(raw);
3557    loop {
3558        match ancestor.parent() {
3559            Some(p) => {
3560                ancestor = p;
3561                if ancestor.is_dir() {
3562                    break;
3563                }
3564            }
3565            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3566        }
3567    }
3568    Ok(ancestor.to_path_buf())
3569}
3570
3571async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3572    match tokio::fs::canonicalize(raw).await {
3573        Ok(canonical) if canonical.is_file() => canonical
3574            .parent()
3575            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3576                Ok(p.to_path_buf())
3577            }),
3578        Ok(canonical) if canonical.is_dir() => Ok(canonical),
3579        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3580        Err(_) => find_existing_ancestor(raw),
3581    }
3582}
3583
3584async fn open_path_handler(
3585    State(state): State<AppState>,
3586    Query(query): Query<OpenPathQuery>,
3587) -> impl IntoResponse {
3588    if state.server_mode {
3589        return Json(serde_json::json!({
3590            "server_mode_disabled": true,
3591            "message": "Opening a path in the file manager is only available in local desktop mode."
3592        }))
3593        .into_response();
3594    }
3595    // Skip the OS file-manager call in headless / CI environments.
3596    if std::env::var("SLOC_HEADLESS").is_ok() {
3597        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3598    }
3599    let raw = match query.path.as_deref() {
3600        Some(p) if !p.is_empty() => p,
3601        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3602    };
3603
3604    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
3605    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
3606    // so the file explorer still opens somewhere useful.
3607    let target = match resolve_open_target(raw).await {
3608        Ok(p) => p,
3609        Err((code, msg)) => return (code, msg).into_response(),
3610    };
3611
3612    #[cfg(target_os = "windows")]
3613    win_dialog_focus::open_folder_foreground(target);
3614    #[cfg(target_os = "macos")]
3615    let _ = std::process::Command::new("open")
3616        .arg(&target)
3617        .stdout(Stdio::null())
3618        .stderr(Stdio::null())
3619        .spawn();
3620    #[cfg(target_os = "linux")]
3621    {
3622        let folder_name = target
3623            .file_name()
3624            .and_then(|n| n.to_str())
3625            .map(str::to_owned);
3626        let _ = std::process::Command::new("xdg-open")
3627            .arg(&target)
3628            .stdout(Stdio::null())
3629            .stderr(Stdio::null())
3630            .spawn();
3631        // Best-effort: raise the file manager window once it appears.
3632        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
3633        // installed; failures are silently discarded.
3634        if let Some(name) = folder_name {
3635            std::thread::spawn(move || {
3636                std::thread::sleep(std::time::Duration::from_millis(800));
3637                let _ = std::process::Command::new("wmctrl")
3638                    .args(["-a", &name])
3639                    .stdout(Stdio::null())
3640                    .stderr(Stdio::null())
3641                    .spawn();
3642            });
3643        }
3644    }
3645
3646    Json(serde_json::json!({"ok": true})).into_response()
3647}
3648
3649async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3650    let (content_type, bytes): (&'static str, &'static [u8]) =
3651        match (folder.as_str(), file.as_str()) {
3652            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3653            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3654            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3655            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3656            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3657            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3658            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3659            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3660            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3661            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3662            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3663            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3664            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3665            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3666            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3667            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3668            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3669            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3670            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3671            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3672            _ => return StatusCode::NOT_FOUND.into_response(),
3673        };
3674    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3675}
3676
3677async fn preview_handler(
3678    State(state): State<AppState>,
3679    Query(query): Query<PreviewQuery>,
3680) -> impl IntoResponse {
3681    let raw_path = query
3682        .path
3683        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3684    let resolved = resolve_input_path(&raw_path);
3685
3686    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3687    // binary whose working directory is not the project root), return a clear message
3688    // instead of an opaque OS error from build_preview_html.
3689    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3690        return Html(
3691            r#"<div class="preview-error">Sample directory not available on this server.
3692            Enter a path to a project directory or upload files using Browse.</div>"#
3693                .to_string(),
3694        );
3695    }
3696
3697    if state.server_mode {
3698        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3699        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3700        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3701            let config = &state.base_config;
3702            if config.discovery.allowed_scan_roots.is_empty() {
3703                return Html(
3704                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3705                );
3706            }
3707            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3708                fs::canonicalize(root)
3709                    .ok()
3710                    .is_some_and(|r| canonical.starts_with(&r))
3711            });
3712            if !allowed {
3713                return Html(
3714                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3715                );
3716            }
3717        }
3718    }
3719
3720    let include_patterns = split_patterns(query.include_globs.as_deref());
3721    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3722
3723    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3724        Ok(html) => Html(html),
3725        Err(err) => Html(format!(
3726            r#"<div class="preview-error">Preview failed: {}</div>"#,
3727            escape_html(&err.to_string())
3728        )),
3729    }
3730}
3731
3732#[derive(Debug, Deserialize, Default)]
3733struct SuggestCoverageQuery {
3734    path: Option<String>,
3735}
3736
3737#[derive(Serialize)]
3738struct SuggestCoverageResponse {
3739    found: Option<String>,
3740    tool: Option<&'static str>,
3741    hint: Option<&'static str>,
3742}
3743
3744async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3745    const CANDIDATES: &[&str] = &[
3746        // LCOV — cargo-llvm-cov, gcov, lcov
3747        "coverage/lcov.info",
3748        "lcov.info",
3749        "target/llvm-cov/lcov.info",
3750        "target/coverage/lcov.info",
3751        "target/debug/coverage/lcov.info",
3752        "coverage/coverage.lcov",
3753        "build/coverage/lcov.info",
3754        "reports/lcov.info",
3755        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3756        "coverage.xml",
3757        "coverage/coverage.xml",
3758        "target/site/cobertura/coverage.xml",
3759        "build/reports/coverage/coverage.xml",
3760        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3761        "target/site/jacoco/jacoco.xml",
3762        "build/reports/jacoco/test/jacocoTestReport.xml",
3763        "build/reports/jacoco/jacocoTestReport.xml",
3764        "build/jacoco/jacoco.xml",
3765    ];
3766    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3767    let found = CANDIDATES
3768        .iter()
3769        .map(|rel| root.join(rel))
3770        .find(|p| p.is_file())
3771        .map(|p| display_path(&p));
3772
3773    let (tool, hint) = detect_coverage_tool(&root);
3774    Json(SuggestCoverageResponse { found, tool, hint })
3775}
3776
3777/// Inspect the project root for known build/package files and return the most likely coverage
3778/// tool name and the shell command needed to generate a coverage file.
3779fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3780    if root.join("Cargo.toml").is_file() {
3781        return (
3782            Some("cargo-llvm-cov"),
3783            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3784        );
3785    }
3786    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3787        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3788    }
3789    if root.join("pom.xml").is_file() {
3790        return (Some("jacoco"), Some("mvn test jacoco:report"));
3791    }
3792    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3793        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3794    }
3795    (None, None)
3796}
3797
3798/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3799#[allow(clippy::result_large_err)]
3800fn validate_server_scan_path(
3801    config: &sloc_config::AppConfig,
3802    resolved_path: &Path,
3803    csp_nonce: &str,
3804) -> Result<(), Response> {
3805    if config.discovery.allowed_scan_roots.is_empty() {
3806        let template = ErrorTemplate {
3807            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3808                      Set allowed_scan_roots in the server config to permit scanning."
3809                .to_string(),
3810            last_report_url: None,
3811            last_report_label: None,
3812            run_id: None,
3813            error_code: Some(403),
3814            csp_nonce: csp_nonce.to_owned(),
3815            version: env!("CARGO_PKG_VERSION"),
3816        };
3817        return Err((
3818            StatusCode::FORBIDDEN,
3819            Html(
3820                template
3821                    .render()
3822                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3823            ),
3824        )
3825            .into_response());
3826    }
3827    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3828    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3829        fs::canonicalize(root)
3830            .ok()
3831            .is_some_and(|r| canonical.starts_with(&r))
3832    });
3833    if !allowed {
3834        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3835            "Scan path not in allowed_scan_roots");
3836        let template = ErrorTemplate {
3837            message: "The requested path is not within an allowed scan directory.".to_string(),
3838            last_report_url: None,
3839            last_report_label: None,
3840            run_id: None,
3841            error_code: Some(403),
3842            csp_nonce: csp_nonce.to_owned(),
3843            version: env!("CARGO_PKG_VERSION"),
3844        };
3845        return Err((
3846            StatusCode::FORBIDDEN,
3847            Html(
3848                template
3849                    .render()
3850                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3851            ),
3852        )
3853            .into_response());
3854    }
3855    Ok(())
3856}
3857
3858/// Exclude the output directory from scanning so artifacts don't pollute counts.
3859fn apply_output_dir_exclusions(
3860    config: &mut sloc_config::AppConfig,
3861    project_path: &str,
3862    raw_output_dir: &str,
3863) {
3864    let project_root = resolve_input_path(project_path);
3865    let raw_out = raw_output_dir.trim();
3866    let resolved_out = if raw_out.is_empty() {
3867        project_root.join("sloc")
3868    } else if Path::new(raw_out).is_absolute() {
3869        PathBuf::from(raw_out)
3870    } else {
3871        workspace_root().join(raw_out)
3872    };
3873    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3874        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3875            let dir = first.to_string();
3876            if !config.discovery.excluded_directories.contains(&dir) {
3877                config.discovery.excluded_directories.push(dir);
3878            }
3879        }
3880    }
3881    if !config
3882        .discovery
3883        .excluded_directories
3884        .iter()
3885        .any(|d| d == "sloc")
3886    {
3887        config
3888            .discovery
3889            .excluded_directories
3890            .push("sloc".to_string());
3891    }
3892}
3893
3894/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3895const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3896    ScanSummarySnapshot {
3897        files_analyzed: run.summary_totals.files_analyzed,
3898        files_skipped: run.summary_totals.files_skipped,
3899        total_physical_lines: run.summary_totals.total_physical_lines,
3900        code_lines: run.summary_totals.code_lines,
3901        comment_lines: run.summary_totals.comment_lines,
3902        blank_lines: run.summary_totals.blank_lines,
3903        functions: run.summary_totals.functions,
3904        classes: run.summary_totals.classes,
3905        variables: run.summary_totals.variables,
3906        imports: run.summary_totals.imports,
3907        test_count: run.summary_totals.test_count,
3908        coverage_lines_found: run.summary_totals.coverage_lines_found,
3909        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3910        coverage_functions_found: run.summary_totals.coverage_functions_found,
3911        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3912        coverage_branches_found: run.summary_totals.coverage_branches_found,
3913        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3914    }
3915}
3916
3917/// Build the `RegistryEntry` for the just-completed scan run.
3918pub(crate) fn build_run_registry_entry(
3919    run: &AnalysisRun,
3920    run_id: &str,
3921    project_label: &str,
3922    artifacts: &RunArtifacts,
3923) -> RegistryEntry {
3924    RegistryEntry {
3925        run_id: run_id.to_owned(),
3926        timestamp_utc: run.tool.timestamp_utc,
3927        project_label: project_label.to_owned(),
3928        input_roots: run.input_roots.clone(),
3929        json_path: artifacts.json_path.clone(),
3930        html_path: artifacts.html_path.clone(),
3931        pdf_path: artifacts.pdf_path.clone(),
3932        csv_path: artifacts.csv_path.clone(),
3933        xlsx_path: artifacts.xlsx_path.clone(),
3934        summary: summary_snapshot_from_run(run),
3935        git_branch: run.git_branch.clone(),
3936        git_commit: run.git_commit_short.clone(),
3937        git_author: run.git_commit_author.clone(),
3938        git_tags: run.git_tags.clone(),
3939        git_nearest_tag: run.git_nearest_tag.clone(),
3940        git_commit_date: run.git_commit_date.clone(),
3941    }
3942}
3943
3944/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3945fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3946    if let Some(policy) = form.mixed_line_policy {
3947        config.analysis.mixed_line_policy = policy;
3948    }
3949    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3950    config.analysis.generated_file_detection =
3951        form.generated_file_detection.as_deref() != Some("disabled");
3952    config.analysis.minified_file_detection =
3953        form.minified_file_detection.as_deref() != Some("disabled");
3954    config.analysis.vendor_directory_detection =
3955        form.vendor_directory_detection.as_deref() != Some("disabled");
3956    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3957    if let Some(binary_behavior) = form.binary_file_behavior {
3958        config.analysis.binary_file_behavior = binary_behavior;
3959    }
3960    apply_report_opts(config, form);
3961    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3962    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3963    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3964    if let Some(policy) = form.continuation_line_policy {
3965        config.analysis.continuation_line_policy = policy;
3966    }
3967    if let Some(policy) = form.blank_in_block_comment_policy {
3968        config.analysis.blank_in_block_comment_policy = policy;
3969    }
3970    config.analysis.count_compiler_directives =
3971        form.count_compiler_directives.as_deref() != Some("disabled");
3972    apply_style_threshold(config, form);
3973    apply_coverage_path(config, form);
3974}
3975
3976fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3977    if let Some(report_title) = form.report_title.as_deref() {
3978        let trimmed = report_title.trim();
3979        if !trimmed.is_empty() {
3980            config.reporting.report_title = trimmed.to_string();
3981        }
3982    }
3983    if let Some(hf) = form.report_header_footer.as_deref() {
3984        let trimmed = hf.trim();
3985        config.reporting.report_header_footer = if trimmed.is_empty() {
3986            None
3987        } else {
3988            Some(trimmed.to_string())
3989        };
3990    }
3991}
3992
3993fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3994    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3995        if let Ok(t) = threshold_str.parse::<u16>() {
3996            if t == 80 || t == 100 || t == 120 {
3997                config.analysis.style_col_threshold = t;
3998            }
3999        }
4000    }
4001    if let Some(v) = form.style_analysis_enabled.as_deref() {
4002        config.analysis.style_analysis_enabled = v != "disabled";
4003    }
4004    if let Some(v) = form.style_score_threshold.as_deref() {
4005        if let Ok(t) = v.parse::<u8>() {
4006            config.analysis.style_score_threshold = t.min(100);
4007        }
4008    }
4009    if let Some(v) = form.style_lang_scope.as_deref() {
4010        let scope = v.trim();
4011        if scope == "c_family" || scope == "all" {
4012            config.analysis.style_lang_scope = scope.to_string();
4013        }
4014    }
4015}
4016
4017fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4018    if let Some(cov) = &form.coverage_file {
4019        let trimmed = cov.trim();
4020        if !trimmed.is_empty() {
4021            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4022        }
4023    }
4024}
4025
4026/// Fire-and-forget: generate the PDF in a background task if one is pending.
4027/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4028/// an error instead of spinning indefinitely.
4029fn spawn_pdf_background(
4030    pending_pdf: PendingPdf,
4031    run_id: String,
4032    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4033) {
4034    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4035        tokio::spawn(async move {
4036            let result = tokio::task::spawn_blocking(move || {
4037                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4038                if cleanup_src {
4039                    let _ = fs::remove_file(&pdf_src);
4040                }
4041                r
4042            })
4043            .await;
4044            let failed = match result {
4045                Ok(Ok(())) => false,
4046                Ok(Err(err)) => {
4047                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4048                    true
4049                }
4050                Err(err) => {
4051                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4052                    true
4053                }
4054            };
4055            if failed {
4056                let mut map = artifacts.lock().await;
4057                if let Some(entry) = map.get_mut(&run_id) {
4058                    entry.pdf_path = None;
4059                }
4060            }
4061        });
4062    }
4063}
4064
4065/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4066/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4067/// result page can show an error on the next visit instead of spinning indefinitely.
4068fn spawn_native_pdf_background(
4069    json_path: PathBuf,
4070    pdf_dest: PathBuf,
4071    run_id: String,
4072    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4073) {
4074    tokio::spawn(async move {
4075        let result = tokio::task::spawn_blocking(move || {
4076            let run = sloc_core::read_json(&json_path)?;
4077            write_pdf_from_run(&run, &pdf_dest)
4078        })
4079        .await;
4080        let failed = match result {
4081            Ok(Ok(())) => false,
4082            Ok(Err(err)) => {
4083                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4084                true
4085            }
4086            Err(err) => {
4087                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4088                true
4089            }
4090        };
4091        if failed {
4092            let mut map = artifacts.lock().await;
4093            if let Some(entry) = map.get_mut(&run_id) {
4094                entry.pdf_path = None;
4095            }
4096        }
4097    });
4098}
4099
4100/// Sum the code lines added in this comparison (new + grown files).
4101fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4102    cmp.file_deltas
4103        .iter()
4104        .map(|f| match f.status {
4105            FileChangeStatus::Added => f.current_code,
4106            FileChangeStatus::Modified => f.code_delta.max(0),
4107            _ => 0,
4108        })
4109        .sum()
4110}
4111
4112/// Sum the code lines removed in this comparison (deleted + shrunk files).
4113fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4114    cmp.file_deltas
4115        .iter()
4116        .map(|f| match f.status {
4117            FileChangeStatus::Removed => f.baseline_code,
4118            FileChangeStatus::Modified => (-f.code_delta).max(0),
4119            _ => 0,
4120        })
4121        .sum()
4122}
4123
4124/// Sum the code lines present in both scans without any change (Unchanged files).
4125fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4126    cmp.file_deltas
4127        .iter()
4128        .filter(|f| f.status == FileChangeStatus::Unchanged)
4129        .map(|f| f.current_code)
4130        .sum()
4131}
4132
4133/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4134fn build_submodule_row(
4135    s: &sloc_core::SubmoduleSummary,
4136    run: &AnalysisRun,
4137    run_id: &str,
4138    run_dir: &Path,
4139) -> SubmoduleRow {
4140    let safe = sanitize_project_label(&s.name);
4141    let artifact_key = format!("sub_{safe}");
4142    let pdf_artifact_key = format!("sub_{safe}_pdf");
4143    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4144        let parent_path = run
4145            .input_roots
4146            .first()
4147            .map_or("", std::string::String::as_str);
4148        let sub_run = build_sub_run(run, s, parent_path);
4149        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4150        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4151            .ok()
4152            .and_then(|sub_html| {
4153                let sub_dir = run_dir.join("submodules");
4154                let _ = fs::create_dir_all(&sub_dir);
4155                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4156                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4157                    // Pre-generate the sub-report PDF using the programmatic renderer
4158                    // so "View PDF" never needs to spawn Chrome for submodules.
4159                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4160                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4161                    Some(format!("/runs/{artifact_key}/{run_id}"))
4162                } else {
4163                    None
4164                }
4165            })
4166    } else {
4167        None
4168    };
4169    SubmoduleRow {
4170        name: s.name.clone(),
4171        relative_path: s.relative_path.clone(),
4172        files_analyzed: s.files_analyzed,
4173        code_lines: s.code_lines,
4174        comment_lines: s.comment_lines,
4175        blank_lines: s.blank_lines,
4176        total_physical_lines: s.total_physical_lines,
4177        html_url,
4178    }
4179}
4180
4181// Immediately returns a wait page and runs the analysis in a background tokio task.
4182// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4183#[allow(clippy::similar_names)]
4184#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4185#[allow(clippy::too_many_lines)]
4186async fn analyze_handler(
4187    State(state): State<AppState>,
4188    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4189    Form(form): Form<AnalyzeForm>,
4190) -> impl IntoResponse {
4191    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4192        let template = ErrorTemplate {
4193            message: format!(
4194                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4195             Please wait a moment and try again."
4196            ),
4197            last_report_url: None,
4198            last_report_label: None,
4199            run_id: None,
4200            error_code: Some(503),
4201            csp_nonce: csp_nonce.clone(),
4202            version: env!("CARGO_PKG_VERSION"),
4203        };
4204        return (
4205            StatusCode::SERVICE_UNAVAILABLE,
4206            Html(
4207                template
4208                    .render()
4209                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4210            ),
4211        )
4212            .into_response();
4213    };
4214
4215    let mut config = state.base_config.clone();
4216
4217    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4218    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4219    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4220
4221    if !is_git_mode {
4222        let resolved_path = resolve_input_path(&form.path);
4223        if state.server_mode
4224            && !is_upload_tmp_path(&resolved_path)
4225            && !is_sample_path(&resolved_path)
4226        {
4227            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4228                return resp;
4229            }
4230        }
4231        config.discovery.root_paths = vec![resolved_path];
4232    }
4233
4234    apply_form_to_config(&mut config, &form);
4235    apply_output_dir_exclusions(
4236        &mut config,
4237        &form.path,
4238        form.output_dir.as_deref().unwrap_or(""),
4239    );
4240
4241    // Generate a wait_id now (before spawning) so the client can poll for status.
4242    let wait_id = uuid::Uuid::new_v4().to_string();
4243    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4244
4245    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4246    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4247    let task_cancel = Arc::clone(&cancel_token);
4248
4249    // Phase tracker: updated by run_analysis_task at key checkpoints.
4250    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4251    let task_phase = Arc::clone(&phase);
4252
4253    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4254    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4255    let task_files_done = Arc::clone(&files_done);
4256    let task_files_total = Arc::clone(&files_total);
4257
4258    // Register Running state before building the task struct so the semaphore permit
4259    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4260    {
4261        let mut runs = state.async_runs.lock().await;
4262        runs.insert(
4263            wait_id.clone(),
4264            AsyncRunState::Running {
4265                started_at: std::time::Instant::now(),
4266                cancel_token,
4267                phase,
4268                files_done,
4269                files_total,
4270            },
4271        );
4272    }
4273
4274    let task = AnalysisTask {
4275        sem_permit,
4276        state: state.clone(),
4277        wait_id: wait_id.clone(),
4278        config,
4279        cancel: task_cancel,
4280        phase: task_phase,
4281        files_done: task_files_done,
4282        files_total: task_files_total,
4283        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4284        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4285        project_path: form.path.clone(),
4286        // In server mode the client-supplied output_dir is ignored — artifacts are
4287        // always written under the server's configured output root so remote users
4288        // cannot direct writes to arbitrary filesystem paths.
4289        output_dir: if state.server_mode {
4290            None
4291        } else {
4292            form.output_dir.clone()
4293        },
4294        clones_dir: state.git_clones_dir.clone(),
4295        cocomo_mode: form
4296            .cocomo_mode
4297            .clone()
4298            .unwrap_or_else(|| "organic".to_string()),
4299        complexity_alert: form
4300            .complexity_alert
4301            .as_deref()
4302            .and_then(|s| s.parse::<u32>().ok())
4303            .unwrap_or(0),
4304        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4305    };
4306
4307    tokio::spawn(run_analysis_task(task));
4308
4309    let template = ScanWaitTemplate {
4310        version: env!("CARGO_PKG_VERSION"),
4311        wait_id_json,
4312        project_path: form.path.clone(),
4313        csp_nonce,
4314    };
4315    let html = template
4316        .render()
4317        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4318    let mut response = Html(html).into_response();
4319    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4320        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4321            response.headers_mut().insert(name, val);
4322        }
4323    }
4324    response
4325}
4326
4327struct AnalysisTask {
4328    sem_permit: tokio::sync::OwnedSemaphorePermit,
4329    state: AppState,
4330    wait_id: String,
4331    config: AppConfig,
4332    cancel: Arc<std::sync::atomic::AtomicBool>,
4333    phase: Arc<std::sync::Mutex<String>>,
4334    files_done: Arc<std::sync::atomic::AtomicUsize>,
4335    files_total: Arc<std::sync::atomic::AtomicUsize>,
4336    git_repo: Option<String>,
4337    git_ref: Option<String>,
4338    project_path: String,
4339    output_dir: Option<String>,
4340    clones_dir: PathBuf,
4341    cocomo_mode: String,
4342    complexity_alert: u32,
4343    exclude_duplicates: bool,
4344}
4345
4346#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4347async fn run_analysis_task(task: AnalysisTask) {
4348    let _permit = task.sem_permit;
4349
4350    let cancel_sb = Arc::clone(&task.cancel);
4351    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4352    let clones_dir_sb = task.clones_dir;
4353    // Save the upload staging path before config is moved into spawn_blocking.
4354    let upload_staging_root = task
4355        .config
4356        .discovery
4357        .root_paths
4358        .first()
4359        .filter(|p| is_upload_tmp_path(p))
4360        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4361        .map(PathBuf::from);
4362    let config_sb = task.config;
4363    let progress_sb = sloc_core::ProgressCounters {
4364        files_done: Arc::clone(&task.files_done),
4365        files_total: Arc::clone(&task.files_total),
4366    };
4367    if let Ok(mut p) = task.phase.lock() {
4368        *p = "Scanning files".to_string();
4369    }
4370    let analysis_result = tokio::task::spawn_blocking(move || {
4371        run_analysis_blocking(
4372            config_sb,
4373            git_repo_sb,
4374            git_ref_sb,
4375            clones_dir_sb,
4376            cancel_sb,
4377            Some(progress_sb),
4378        )
4379    })
4380    .await
4381    .map_err(|err| anyhow::anyhow!(err.to_string()))
4382    .and_then(|result| result);
4383
4384    if let Ok(mut p) = task.phase.lock() {
4385        *p = "Writing reports".to_string();
4386    }
4387
4388    // If cancelled while running, discard results and mark as cancelled.
4389    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4390        let mut runs = task.state.async_runs.lock().await;
4391        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4392        if matches!(
4393            runs.get(&task.wait_id),
4394            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4395        ) {
4396            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4397        }
4398        drop(runs);
4399        return;
4400    }
4401
4402    let run = match analysis_result {
4403        Ok(v) => v,
4404        Err(err) => {
4405            // Distinguish user-cancelled from real failure.
4406            if err.to_string().contains("analysis cancelled") {
4407                let mut runs = task.state.async_runs.lock().await;
4408                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4409                drop(runs);
4410                return;
4411            }
4412            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4413            let mut runs = task.state.async_runs.lock().await;
4414            runs.insert(
4415                task.wait_id.clone(),
4416                AsyncRunState::Failed {
4417                    message: "Analysis failed. Check that the path exists and is readable."
4418                        .to_string(),
4419                },
4420            );
4421            drop(runs);
4422            return;
4423        }
4424    };
4425
4426    let run_id = run.tool.run_id.clone();
4427    tracing::info!(event = "scan_complete", run_id = %run_id,
4428        path = %task.project_path, files = run.summary_totals.files_analyzed,
4429        "Analysis finished");
4430
4431    let prev_entry: Option<RegistryEntry> = {
4432        let reg = task.state.registry.lock().await;
4433        reg.entries_for_roots(&run.input_roots)
4434            .into_iter()
4435            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4436            .cloned()
4437    };
4438
4439    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4440        prev.json_path
4441            .as_ref()
4442            .and_then(|p| read_json(p).ok())
4443            .map(|prev_run| compute_delta(&prev_run, &run))
4444    });
4445    let prev_scan_count: usize = {
4446        let reg = task.state.registry.lock().await;
4447        reg.entries_for_roots(&run.input_roots)
4448            .iter()
4449            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4450            .count()
4451    };
4452
4453    // Build the HTML report now that delta is available, so the artifact
4454    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
4455    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4456        .as_ref()
4457        .zip(prev_entry.as_ref())
4458        .map(|(cmp, prev)| ReportDeltaContext {
4459            delta_code_added: sum_added_code_lines(cmp),
4460            delta_code_removed: sum_removed_code_lines(cmp),
4461            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4462            delta_files_added: cmp.files_added,
4463            delta_files_removed: cmp.files_removed,
4464            delta_files_modified: cmp.files_modified,
4465            delta_files_unchanged: cmp.files_unchanged,
4466            prev_code_lines: prev.summary.code_lines,
4467            prev_scan_count: prev_scan_count + 1,
4468            prev_scan_label: fmt_la_time(prev.timestamp_utc),
4469            prev_run_id: Some(prev.run_id.clone()),
4470            current_run_id: Some(run_id.clone()),
4471        });
4472    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4473        Ok(h) => h,
4474        Err(err) => {
4475            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4476            let mut runs = task.state.async_runs.lock().await;
4477            runs.insert(
4478                task.wait_id.clone(),
4479                AsyncRunState::Failed {
4480                    message: "Failed to render HTML report.".to_string(),
4481                },
4482            );
4483            drop(runs);
4484            return;
4485        }
4486    };
4487
4488    let output_root = resolve_output_root(task.output_dir.as_deref());
4489    let project_label = derive_project_label(
4490        task.git_repo.as_deref(),
4491        task.git_ref.as_deref(),
4492        &task.project_path,
4493    );
4494    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4495    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4496
4497    let result_context = RunResultContext {
4498        prev_entry: prev_entry.clone(),
4499        prev_scan_count,
4500        project_path: task.project_path.clone(),
4501        cocomo_mode: task.cocomo_mode.clone(),
4502        complexity_alert: task.complexity_alert,
4503        exclude_duplicates: task.exclude_duplicates,
4504    };
4505
4506    let artifact_result = persist_run_artifacts(
4507        &run,
4508        &report_html,
4509        &run_dir,
4510        &run.effective_configuration.reporting.report_title,
4511        &file_stem,
4512        result_context,
4513    );
4514
4515    let (artifacts, pending_pdf) = match artifact_result {
4516        Ok(v) => v,
4517        Err(err) => {
4518            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4519            let mut runs = task.state.async_runs.lock().await;
4520            runs.insert(
4521                task.wait_id.clone(),
4522                AsyncRunState::Failed {
4523                    message: "Failed to save report artifacts. Check available disk space."
4524                        .to_string(),
4525                },
4526            );
4527            drop(runs);
4528            return;
4529        }
4530    };
4531
4532    {
4533        let mut map = task.state.artifacts.lock().await;
4534        map.insert(run_id.clone(), artifacts.clone());
4535    }
4536
4537    {
4538        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4539        let mut reg = task.state.registry.lock().await;
4540        reg.add_entry(entry);
4541        let _ = reg.save(&task.state.registry_path);
4542    }
4543
4544    if let Some(ref cfg_path) = artifacts.scan_config_path {
4545        save_scan_config_json(
4546            cfg_path,
4547            &run,
4548            &task.project_path,
4549            task.output_dir.as_deref(),
4550        );
4551    }
4552
4553    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4554
4555    prom_runs_total().inc();
4556
4557    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
4558    let mut runs = task.state.async_runs.lock().await;
4559    runs.insert(
4560        task.wait_id.clone(),
4561        AsyncRunState::Complete {
4562            run_id: run_id.clone(),
4563        },
4564    );
4565    drop(runs);
4566
4567    // Remove the client-upload staging directory after a successful scan so
4568    // that uploaded project files don't accumulate in the OS temp directory.
4569    if let Some(staging) = upload_staging_root {
4570        let _ = tokio::fs::remove_dir_all(staging).await;
4571    }
4572
4573    let _ = scan_delta;
4574}
4575
4576fn save_scan_config_json(
4577    cfg_path: &std::path::Path,
4578    run: &sloc_core::AnalysisRun,
4579    project_path: &str,
4580    output_dir: Option<&str>,
4581) {
4582    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4583        .ok()
4584        .and_then(|v| v.as_str().map(String::from))
4585        .unwrap_or_else(|| "code_only".to_string());
4586    let behavior_str =
4587        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4588            .ok()
4589            .and_then(|v| v.as_str().map(String::from))
4590            .unwrap_or_else(|| "skip".to_string());
4591    let scan_cfg = ScanConfig {
4592        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4593        path: project_path.to_string(),
4594        include_globs: run
4595            .effective_configuration
4596            .discovery
4597            .include_globs
4598            .join("\n"),
4599        exclude_globs: run
4600            .effective_configuration
4601            .discovery
4602            .exclude_globs
4603            .join("\n"),
4604        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4605        mixed_line_policy: policy_str,
4606        python_docstrings_as_comments: run
4607            .effective_configuration
4608            .analysis
4609            .python_docstrings_as_comments,
4610        generated_file_detection: run
4611            .effective_configuration
4612            .analysis
4613            .generated_file_detection,
4614        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4615        vendor_directory_detection: run
4616            .effective_configuration
4617            .analysis
4618            .vendor_directory_detection,
4619        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4620        binary_file_behavior: behavior_str,
4621        output_dir: output_dir.unwrap_or("").to_string(),
4622        report_title: run.effective_configuration.reporting.report_title.clone(),
4623    };
4624    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4625        let _ = std::fs::write(cfg_path, json);
4626    }
4627}
4628
4629#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
4630fn run_analysis_blocking(
4631    mut config: AppConfig,
4632    git_repo: Option<String>,
4633    git_ref: Option<String>,
4634    clones_dir: PathBuf,
4635    cancel: Arc<std::sync::atomic::AtomicBool>,
4636    progress: Option<sloc_core::ProgressCounters>,
4637) -> Result<sloc_core::AnalysisRun> {
4638    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4639        let dest = git_clone_dest(&repo, &clones_dir);
4640        sloc_git::clone_or_fetch(&repo, &dest)?;
4641        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4642        sloc_git::create_worktree(&dest, &refname, &wt)?;
4643        config.discovery.root_paths = vec![wt.clone()];
4644        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4645        let _ = sloc_git::destroy_worktree(&dest, &wt);
4646        let mut run = run?;
4647        if run.git_branch.is_none() {
4648            run.git_branch = Some(refname);
4649        }
4650        return Ok(run);
4651    }
4652    analyze(&config, "serve", Some(&cancel), progress.as_ref())
4653}
4654
4655fn derive_project_label(
4656    git_repo: Option<&str>,
4657    git_ref: Option<&str>,
4658    fallback_path: &str,
4659) -> String {
4660    match (
4661        git_repo.filter(|s| !s.is_empty()),
4662        git_ref.filter(|s| !s.is_empty()),
4663    ) {
4664        (Some(repo), Some(refname)) => {
4665            let repo_name = repo
4666                .trim_end_matches('/')
4667                .trim_end_matches(".git")
4668                .rsplit('/')
4669                .next()
4670                .unwrap_or("repo");
4671            sanitize_project_label(&format!("{repo_name}_{refname}"))
4672        }
4673        _ => sanitize_project_label(fallback_path),
4674    }
4675}
4676
4677fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4678    let commit = commit_short.unwrap_or("").trim();
4679    if commit.is_empty() {
4680        project_label.to_string()
4681    } else {
4682        format!("{project_label}_{commit}")
4683    }
4684}
4685
4686// ── Async scan status + result handlers ──────────────────────────────────────
4687
4688#[derive(Serialize)]
4689#[serde(tag = "state", rename_all = "snake_case")]
4690enum AsyncRunStatusResponse {
4691    Running {
4692        elapsed_secs: u64,
4693        phase: String,
4694        files_done: u64,
4695        files_total: u64,
4696    },
4697    Complete {
4698        run_id: String,
4699    },
4700    Failed {
4701        message: String,
4702    },
4703    Cancelled,
4704}
4705
4706async fn async_run_status_handler(
4707    State(state): State<AppState>,
4708    AxumPath(wait_id): AxumPath<String>,
4709) -> Response {
4710    // wait_id comes from our own UUID generator; reject any structurally malformed value.
4711    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4712        return error::bad_request("invalid wait_id");
4713    }
4714    let run_state = {
4715        let runs = state.async_runs.lock().await;
4716        runs.get(&wait_id).cloned()
4717    };
4718    match run_state {
4719        None => error::not_found("run not found"),
4720        Some(AsyncRunState::Running {
4721            started_at,
4722            phase,
4723            files_done,
4724            files_total,
4725            ..
4726        }) => {
4727            // Treat runs older than 2 h as timed out (analysis should finish well under that).
4728            if started_at.elapsed() > std::time::Duration::from_hours(2) {
4729                let mut runs = state.async_runs.lock().await;
4730                runs.insert(
4731                    wait_id,
4732                    AsyncRunState::Failed {
4733                        message: "Analysis timed out after 2 hours.".to_string(),
4734                    },
4735                );
4736                drop(runs);
4737                return Json(AsyncRunStatusResponse::Failed {
4738                    message: "Analysis timed out after 2 hours.".to_string(),
4739                })
4740                .into_response();
4741            }
4742            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4743            Json(AsyncRunStatusResponse::Running {
4744                elapsed_secs: started_at.elapsed().as_secs(),
4745                phase: phase_str,
4746                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4747                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4748            })
4749            .into_response()
4750        }
4751        Some(AsyncRunState::Complete { run_id }) => {
4752            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4753        }
4754        Some(AsyncRunState::Failed { message }) => {
4755            Json(AsyncRunStatusResponse::Failed { message }).into_response()
4756        }
4757        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4758    }
4759}
4760
4761async fn cancel_run_handler(
4762    State(state): State<AppState>,
4763    AxumPath(wait_id): AxumPath<String>,
4764) -> Response {
4765    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4766        return error::bad_request("invalid wait_id");
4767    }
4768    let mut runs = state.async_runs.lock().await;
4769    let resp = match runs.get(&wait_id) {
4770        Some(AsyncRunState::Running { cancel_token, .. }) => {
4771            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4772            runs.insert(wait_id, AsyncRunState::Cancelled);
4773            StatusCode::OK.into_response()
4774        }
4775        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4776        _ => error::not_found("run not found"),
4777    };
4778    drop(runs);
4779    resp
4780}
4781
4782async fn async_run_result_handler(
4783    State(state): State<AppState>,
4784    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4785    AxumPath(run_id): AxumPath<String>,
4786) -> Response {
4787    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4788        return StatusCode::BAD_REQUEST.into_response();
4789    }
4790
4791    let artifacts = {
4792        let map = state.artifacts.lock().await;
4793        map.get(&run_id).cloned()
4794    };
4795    let artifacts = if let Some(a) = artifacts {
4796        a
4797    } else {
4798        let reg = state.registry.lock().await;
4799        if let Some(entry) = reg.find_by_run_id(&run_id) {
4800            recover_artifacts_from_registry(entry)
4801        } else {
4802            let html = ErrorTemplate {
4803                message: format!(
4804                    "Report not found. Run ID {} is not in the scan history.",
4805                    &run_id[..run_id.len().min(8)]
4806                ),
4807                last_report_url: Some("/view-reports".to_string()),
4808                last_report_label: Some("View Reports".to_string()),
4809                run_id: Some(run_id.clone()),
4810                error_code: Some(404),
4811                csp_nonce: csp_nonce.clone(),
4812                version: env!("CARGO_PKG_VERSION"),
4813            }
4814            .render()
4815            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4816            return (StatusCode::NOT_FOUND, Html(html)).into_response();
4817        }
4818    };
4819
4820    let json_path = if let Some(p) = &artifacts.json_path {
4821        p.clone()
4822    } else {
4823        let html = ErrorTemplate {
4824            message: "JSON result was not saved for this run.".to_string(),
4825            last_report_url: Some("/view-reports".to_string()),
4826            last_report_label: Some("View Reports".to_string()),
4827            run_id: Some(run_id.clone()),
4828            error_code: Some(404),
4829            csp_nonce: csp_nonce.clone(),
4830            version: env!("CARGO_PKG_VERSION"),
4831        }
4832        .render()
4833        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4834        return (StatusCode::NOT_FOUND, Html(html)).into_response();
4835    };
4836
4837    let Ok(run) = read_json(&json_path) else {
4838        let folder_hint = json_path
4839            .parent()
4840            .map(|p| p.display().to_string())
4841            .unwrap_or_default();
4842        let redirect_url = format!("/runs/result/{run_id}");
4843        return missing_scan_relocate_response(
4844            &format!(
4845                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4846                 deleted. Browse to the folder containing your scan output to reconnect it.",
4847                json_path.display()
4848            ),
4849            &run_id,
4850            &folder_hint,
4851            &redirect_url,
4852            state.server_mode,
4853            &csp_nonce,
4854        );
4855    };
4856
4857    let confluence_configured = {
4858        let store = state.confluence.lock().await;
4859        store.is_configured()
4860    };
4861
4862    render_result_page(
4863        &run,
4864        &artifacts,
4865        &run_id,
4866        &csp_nonce,
4867        confluence_configured,
4868        state.server_mode,
4869    )
4870}
4871
4872#[allow(clippy::too_many_lines)]
4873#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4874#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
4875fn render_result_page(
4876    run: &AnalysisRun,
4877    artifacts: &RunArtifacts,
4878    run_id: &str,
4879    csp_nonce: &str,
4880    confluence_configured: bool,
4881    server_mode: bool,
4882) -> Response {
4883    let ctx = &artifacts.result_context;
4884    let prev_entry = &ctx.prev_entry;
4885    let prev_scan_count = ctx.prev_scan_count;
4886    let project_path = &ctx.project_path;
4887
4888    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4889        prev.json_path
4890            .as_ref()
4891            .and_then(|p| read_json(p).ok())
4892            .map(|prev_run| compute_delta(&prev_run, run))
4893    });
4894
4895    let files_analyzed = run.per_file_records.len() as u64;
4896    let files_skipped = run.skipped_file_records.len() as u64;
4897    let physical_lines = run
4898        .totals_by_language
4899        .iter()
4900        .map(|r| r.total_physical_lines)
4901        .sum::<u64>();
4902    let code_lines = run
4903        .totals_by_language
4904        .iter()
4905        .map(|r| r.code_lines)
4906        .sum::<u64>();
4907    let comment_lines = run
4908        .totals_by_language
4909        .iter()
4910        .map(|r| r.comment_lines)
4911        .sum::<u64>();
4912    let blank_lines = run
4913        .totals_by_language
4914        .iter()
4915        .map(|r| r.blank_lines)
4916        .sum::<u64>();
4917    let mixed_lines = run
4918        .totals_by_language
4919        .iter()
4920        .map(|r| r.mixed_lines_separate)
4921        .sum::<u64>();
4922    let functions = run
4923        .totals_by_language
4924        .iter()
4925        .map(|r| r.functions)
4926        .sum::<u64>();
4927    let classes = run
4928        .totals_by_language
4929        .iter()
4930        .map(|r| r.classes)
4931        .sum::<u64>();
4932    let variables = run
4933        .totals_by_language
4934        .iter()
4935        .map(|r| r.variables)
4936        .sum::<u64>();
4937    let imports = run
4938        .totals_by_language
4939        .iter()
4940        .map(|r| r.imports)
4941        .sum::<u64>();
4942
4943    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4944    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4945    let prev_fs = prev_sum.map(|s| s.files_skipped);
4946    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4947    let prev_cl = prev_sum.map(|s| s.code_lines);
4948    let prev_cml = prev_sum.map(|s| s.comment_lines);
4949    let prev_bl = prev_sum.map(|s| s.blank_lines);
4950    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4951    let prev_fa_str = fmt_prev(prev_fa);
4952    let prev_fs_str = fmt_prev(prev_fs);
4953    let prev_pl_str = fmt_prev(prev_pl);
4954    let prev_cl_str = fmt_prev(prev_cl);
4955    let prev_cml_str = fmt_prev(prev_cml);
4956    let prev_bl_str = fmt_prev(prev_bl);
4957    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4958    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4959    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4960    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4961    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4962    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4963    let delta_fa_class = delta_fa_class.to_string();
4964    let delta_fs_class = delta_fs_class.to_string();
4965    let delta_pl_class = delta_pl_class.to_string();
4966    let delta_cl_class = delta_cl_class.to_string();
4967    let delta_cml_class = delta_cml_class.to_string();
4968    let delta_bl_class = delta_bl_class.to_string();
4969
4970    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4971    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4972    let (delta_lines_net_str, delta_lines_net_class) =
4973        match (delta_lines_added, delta_lines_removed) {
4974            (Some(a), Some(r)) => {
4975                let net = a - r;
4976                (fmt_delta(net), delta_class(net).to_string())
4977            }
4978            _ => ("—".to_string(), "na".to_string()),
4979        };
4980
4981    let run_dir = artifacts.output_dir.clone();
4982    let git_branch = run.git_branch.clone();
4983    let git_commit = run.git_commit_short.clone();
4984    let git_commit_long = run.git_commit_long.clone();
4985    let git_author = run.git_commit_author.clone();
4986    let git_commit_url = run
4987        .git_remote_url
4988        .as_deref()
4989        .zip(run.git_commit_long.as_deref())
4990        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4991    let git_branch_url = run
4992        .git_remote_url
4993        .as_deref()
4994        .zip(run.git_branch.as_deref())
4995        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
4996    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4997        format!(
4998            "{} / {}",
4999            run.environment.initiator_username, run.environment.initiator_hostname
5000        )
5001    });
5002    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5003    let os_display = format!(
5004        "{} / {}",
5005        run.environment.operating_system, run.environment.architecture
5006    );
5007    let test_count = run.summary_totals.test_count;
5008
5009    // ── New metrics ──────────────────────────────────────────────────────────
5010    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5011    let lsloc = run.summary_totals.lsloc;
5012    let uloc = run.uloc;
5013    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5014    let duplicate_group_count = run.duplicate_groups.len();
5015
5016    // Re-compute COCOMO with the mode selected in the scan wizard.
5017    let ctx = &artifacts.result_context;
5018    let (
5019        has_cocomo,
5020        cocomo_effort_str,
5021        cocomo_duration_str,
5022        cocomo_staff_str,
5023        cocomo_ksloc_str,
5024        cocomo_mode_label,
5025        cocomo_mode_tooltip,
5026    ) = {
5027        let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5028        let mode_str = ctx.cocomo_mode.as_str();
5029        let (a, b, c, d, label, tooltip): (f64, f64, f64, f64, &str, &str) = match mode_str {
5030            "semi_detached" => (3.0, 1.12, 2.5, 0.35, "Semi-detached",
5031                "Semi-detached: A mixed team with varying experience tackling a project with \
5032                 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5033                 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."),
5034            "embedded" => (3.6, 1.20, 2.5, 0.32, "Embedded",
5035                "Embedded: Tight hardware, software, or operational constraints requiring \
5036                 significant innovation and deep integration work. Typical for real-time control \
5037                 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."),
5038            _ => (2.4, 1.05, 2.5, 0.38, "Organic",
5039                "Organic: A small team working on a well-understood project in a familiar \
5040                 environment with minimal external constraints. Suited for internal tools, \
5041                 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."),
5042        };
5043        let effort = a * ksloc.powf(b);
5044        let duration = c * effort.powf(d);
5045        let staff = if duration > 0.0 {
5046            effort / duration
5047        } else {
5048            0.0
5049        };
5050        if run.summary_totals.code_lines > 0 {
5051            (
5052                true,
5053                format!("{:.2}", (effort * 100.0).round() / 100.0),
5054                format!("{:.2}", (duration * 100.0).round() / 100.0),
5055                format!("{:.2}", (staff * 100.0).round() / 100.0),
5056                format!("{:.2}", (ksloc * 100.0).round() / 100.0),
5057                label.to_string(),
5058                tooltip.to_string(),
5059            )
5060        } else {
5061            (
5062                false,
5063                String::new(),
5064                String::new(),
5065                String::new(),
5066                String::new(),
5067                label.to_string(),
5068                tooltip.to_string(),
5069            )
5070        }
5071    };
5072    let complexity_alert = ctx.complexity_alert;
5073
5074    let template = ResultTemplate {
5075        version: env!("CARGO_PKG_VERSION"),
5076        report_title: run.effective_configuration.reporting.report_title.clone(),
5077        project_path: project_path.clone(),
5078        output_dir: display_path(&artifacts.output_dir),
5079        run_id: run_id.to_owned(),
5080        run_id_short: run_id
5081            .split('-')
5082            .next_back()
5083            .unwrap_or(run_id)
5084            .chars()
5085            .take(7)
5086            .collect(),
5087        files_analyzed,
5088        files_skipped,
5089        physical_lines,
5090        code_lines,
5091        comment_lines,
5092        blank_lines,
5093        mixed_lines,
5094        functions,
5095        classes,
5096        variables,
5097        imports,
5098        html_url: artifacts
5099            .html_path
5100            .as_ref()
5101            .map(|_| format!("/runs/html/{run_id}")),
5102        pdf_url: artifacts
5103            .pdf_path
5104            .as_ref()
5105            .map(|_| format!("/runs/pdf/{run_id}")),
5106        json_url: artifacts
5107            .json_path
5108            .as_ref()
5109            .map(|_| format!("/runs/json/{run_id}")),
5110        html_download_url: artifacts
5111            .html_path
5112            .as_ref()
5113            .map(|_| format!("/runs/html/{run_id}?download=1")),
5114        pdf_download_url: artifacts
5115            .pdf_path
5116            .as_ref()
5117            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5118        json_download_url: artifacts
5119            .json_path
5120            .as_ref()
5121            .map(|_| format!("/runs/json/{run_id}?download=1")),
5122        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5123        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5124        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5125        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5126        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5127        prev_fa_str,
5128        prev_fs_str,
5129        prev_pl_str,
5130        prev_cl_str,
5131        prev_cml_str,
5132        prev_bl_str,
5133        delta_fa_str,
5134        delta_fa_class,
5135        delta_fs_str,
5136        delta_fs_class,
5137        delta_pl_str,
5138        delta_pl_class,
5139        delta_cl_str,
5140        delta_cl_class,
5141        delta_cml_str,
5142        delta_cml_class,
5143        delta_bl_str,
5144        delta_bl_class,
5145        delta_lines_added,
5146        delta_lines_removed,
5147        delta_lines_net_str,
5148        delta_lines_net_class,
5149        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
5150        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
5151        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
5152        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
5153        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
5154            d.file_deltas
5155                .iter()
5156                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5157                .map(|f| {
5158                    #[allow(clippy::cast_sign_loss)]
5159                    let n = f.current_code as u64;
5160                    n
5161                })
5162                .sum()
5163        }),
5164        git_branch,
5165        git_branch_url,
5166        git_commit,
5167        git_commit_long,
5168        git_author,
5169        git_commit_url,
5170        scan_performed_by,
5171        scan_time_display,
5172        os_display,
5173        test_count,
5174        current_scan_number: prev_scan_count + 1,
5175        prev_scan_count,
5176        submodule_rows: run
5177            .submodule_summaries
5178            .iter()
5179            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
5180            .collect(),
5181        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
5182        scan_config_url: format!("/runs/scan-config/{run_id}"),
5183        lang_chart_json: {
5184            let mut langs: Vec<&sloc_core::LanguageSummary> =
5185                run.totals_by_language.iter().collect();
5186            langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5187            let entries: Vec<String> = langs
5188                .into_iter()
5189                .take(12)
5190                .map(|l| {
5191                    let name = l
5192                        .language
5193                        .display_name()
5194                        .replace('\\', "\\\\")
5195                        .replace('"', "\\\"");
5196                    format!(
5197                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5198                        name,
5199                        l.code_lines,
5200                        l.comment_lines,
5201                        l.blank_lines,
5202                        l.total_physical_lines,
5203                        l.functions,
5204                        l.classes,
5205                        l.variables,
5206                        l.imports,
5207                        l.files,
5208                    )
5209                })
5210                .collect();
5211            format!("[{}]", entries.join(","))
5212        },
5213        scatter_chart_json: {
5214            let entries: Vec<String> = run
5215                .totals_by_language
5216                .iter()
5217                .map(|l| {
5218                    let name = l
5219                        .language
5220                        .display_name()
5221                        .replace('\\', "\\\\")
5222                        .replace('"', "\\\"");
5223                    format!(
5224                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5225                        name, l.files, l.code_lines, l.total_physical_lines,
5226                    )
5227                })
5228                .collect();
5229            format!("[{}]", entries.join(","))
5230        },
5231        semantic_chart_json: {
5232            let entries: Vec<String> = run
5233                .totals_by_language
5234                .iter()
5235                .filter(|l| {
5236                    l.functions > 0
5237                        || l.classes > 0
5238                        || l.variables > 0
5239                        || l.imports > 0
5240                        || l.test_count > 0
5241                })
5242                .map(|l| {
5243                    let name = l
5244                        .language
5245                        .display_name()
5246                        .replace('\\', "\\\\")
5247                        .replace('"', "\\\"");
5248                    format!(
5249                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5250                        name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5251                    )
5252                })
5253                .collect();
5254            format!("[{}]", entries.join(","))
5255        },
5256        submodule_chart_json: {
5257            let entries: Vec<String> = run
5258                .submodule_summaries
5259                .iter()
5260                .map(|s| {
5261                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5262                    format!(
5263                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5264                        name,
5265                        s.code_lines,
5266                        s.comment_lines,
5267                        s.blank_lines,
5268                        s.total_physical_lines,
5269                        s.files_analyzed,
5270                    )
5271                })
5272                .collect();
5273            format!("[{}]", entries.join(","))
5274        },
5275        has_submodule_data: !run.submodule_summaries.is_empty(),
5276        has_semantic_data: run
5277            .totals_by_language
5278            .iter()
5279            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5280        csp_nonce: csp_nonce.to_owned(),
5281        confluence_configured,
5282        server_mode,
5283        report_header_footer: run
5284            .effective_configuration
5285            .reporting
5286            .report_header_footer
5287            .clone(),
5288        is_offline: false,
5289        cyclomatic_complexity,
5290        lsloc,
5291        uloc,
5292        dryness_pct_str,
5293        duplicate_group_count,
5294        has_cocomo,
5295        cocomo_effort_str,
5296        cocomo_duration_str,
5297        cocomo_staff_str,
5298        cocomo_ksloc_str,
5299        cocomo_mode_label,
5300        cocomo_mode_tooltip,
5301        complexity_alert,
5302    };
5303
5304    Html(
5305        template
5306            .render()
5307            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5308    )
5309    .into_response()
5310}
5311
5312fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5313    let slug: String = report_title
5314        .chars()
5315        .map(|c| {
5316            if c.is_alphanumeric() || c == '-' {
5317                c.to_ascii_lowercase()
5318            } else {
5319                '_'
5320            }
5321        })
5322        .collect::<String>()
5323        .split('_')
5324        .filter(|s| !s.is_empty())
5325        .collect::<Vec<_>>()
5326        .join("_");
5327
5328    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5329
5330    if slug.is_empty() {
5331        format!("report_{short_id}.pdf")
5332    } else {
5333        format!("{slug}_{short_id}.pdf")
5334    }
5335}
5336
5337#[derive(Serialize)]
5338struct PdfStatusResponse {
5339    ready: bool,
5340}
5341
5342/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
5343/// Clients poll this to update the button state without page reloads.
5344async fn pdf_status_handler(
5345    State(state): State<AppState>,
5346    AxumPath(run_id): AxumPath<String>,
5347) -> Response {
5348    let pdf_path = {
5349        let registry = state.artifacts.lock().await;
5350        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5351    };
5352    let pdf_path = if pdf_path.is_some() {
5353        pdf_path
5354    } else {
5355        let reg = state.registry.lock().await;
5356        reg.find_by_run_id(&run_id)
5357            .map(recover_artifacts_from_registry)
5358            .and_then(|a| a.pdf_path)
5359    };
5360    let ready = pdf_path.is_some_and(|p| p.exists());
5361    Json(PdfStatusResponse { ready }).into_response()
5362}
5363
5364/// GET /`api/runs/:run_id/bundle`
5365///
5366/// Streams a gzip-compressed tar archive containing every artifact in the run's
5367/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
5368/// is built in memory so it never touches a temp file.
5369async fn download_bundle_handler(
5370    State(state): State<AppState>,
5371    AxumPath(run_id): AxumPath<String>,
5372) -> Response {
5373    // Resolve output directory from in-memory cache or persisted registry.
5374    let output_dir = {
5375        let cache = state.artifacts.lock().await;
5376        cache.get(&run_id).map(|a| a.output_dir.clone())
5377    };
5378    let output_dir = if let Some(d) = output_dir {
5379        d
5380    } else {
5381        let reg = state.registry.lock().await;
5382        match reg.find_by_run_id(&run_id) {
5383            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5384            None => {
5385                return (
5386                    StatusCode::NOT_FOUND,
5387                    Json(serde_json::json!({"error": "Run not found"})),
5388                )
5389                    .into_response();
5390            }
5391        }
5392    };
5393
5394    if !output_dir.exists() {
5395        return (
5396            StatusCode::NOT_FOUND,
5397            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5398        )
5399            .into_response();
5400    }
5401
5402    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
5403    let run_id_clone = run_id.clone();
5404    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5405        use flate2::{write::GzEncoder, Compression};
5406        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5407        {
5408            let mut tar = tar::Builder::new(&mut enc);
5409            tar.follow_symlinks(false);
5410            // Append every regular file in the output directory, skipping
5411            // sub-directories (the output dir is always flat).
5412            if let Ok(entries) = std::fs::read_dir(&output_dir) {
5413                for entry in entries.filter_map(Result::ok) {
5414                    let p = entry.path();
5415                    if p.is_file() {
5416                        let name = p.file_name().unwrap_or_default().to_string_lossy();
5417                        let archive_path = format!("{run_id_clone}/{name}");
5418                        tar.append_path_with_name(&p, &archive_path)?;
5419                    }
5420                }
5421            }
5422            tar.finish()?;
5423        }
5424        Ok(enc.finish()?)
5425    })
5426    .await;
5427
5428    match archive_result {
5429        Ok(Ok(bytes)) => {
5430            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5431            axum::response::Response::builder()
5432                .status(StatusCode::OK)
5433                .header("Content-Type", "application/gzip")
5434                .header(
5435                    "Content-Disposition",
5436                    format!("attachment; filename=\"{filename}\""),
5437                )
5438                .header("Content-Length", bytes.len().to_string())
5439                .body(axum::body::Body::from(bytes))
5440                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5441        }
5442        Ok(Err(e)) => (
5443            StatusCode::INTERNAL_SERVER_ERROR,
5444            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5445        )
5446            .into_response(),
5447        Err(e) => (
5448            StatusCode::INTERNAL_SERVER_ERROR,
5449            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5450        )
5451            .into_response(),
5452    }
5453}
5454
5455/// DELETE /`api/runs/:run_id`
5456///
5457/// Removes all on-disk artifacts for the run and purges the run from the
5458/// in-memory cache and the persisted registry. Returns 204 on success.
5459async fn delete_run_handler(
5460    State(state): State<AppState>,
5461    AxumPath(run_id): AxumPath<String>,
5462) -> Response {
5463    // Resolve output directory.
5464    let output_dir = {
5465        let mut cache = state.artifacts.lock().await;
5466        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5467        cache.remove(&run_id);
5468        dir
5469    };
5470    let output_dir = if let Some(d) = output_dir {
5471        d
5472    } else {
5473        let reg = state.registry.lock().await;
5474        reg.find_by_run_id(&run_id)
5475            .map(|e| recover_artifacts_from_registry(e).output_dir)
5476            .unwrap_or_default()
5477    };
5478
5479    // Remove from persisted registry.
5480    {
5481        let mut reg = state.registry.lock().await;
5482        reg.entries.retain(|e| e.run_id != run_id);
5483        let _ = reg.save(&state.registry_path);
5484    }
5485
5486    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
5487    // a prior delete may have already removed the directory.
5488    if output_dir.exists() {
5489        match tokio::fs::remove_dir_all(&output_dir).await {
5490            Ok(()) => {}
5491            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
5492            Err(e) => {
5493                return (
5494                    StatusCode::INTERNAL_SERVER_ERROR,
5495                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5496                )
5497                    .into_response();
5498            }
5499        }
5500    }
5501
5502    StatusCode::NO_CONTENT.into_response()
5503}
5504
5505/// POST /api/runs/cleanup
5506///
5507/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
5508/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
5509async fn cleanup_runs_handler(
5510    State(state): State<AppState>,
5511    Json(body): Json<serde_json::Value>,
5512) -> Response {
5513    let days = body
5514        .get("older_than_days")
5515        .and_then(serde_json::Value::as_u64)
5516        .unwrap_or(30)
5517        .max(1);
5518
5519    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5520
5521    // Collect expired entries from the registry.
5522    let expired: Vec<(String, PathBuf)> = {
5523        let reg = state.registry.lock().await;
5524        reg.entries
5525            .iter()
5526            .filter(|e| e.timestamp_utc < cutoff)
5527            .map(|e| {
5528                let arts = recover_artifacts_from_registry(e);
5529                (e.run_id.clone(), arts.output_dir)
5530            })
5531            .collect()
5532    };
5533
5534    let mut deleted = 0usize;
5535    for (run_id, output_dir) in &expired {
5536        // Remove from in-memory cache.
5537        state.artifacts.lock().await.remove(run_id);
5538        // Delete on-disk artifacts (non-fatal if already gone).
5539        if output_dir.exists() {
5540            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5541                eprintln!(
5542                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5543                    output_dir.display()
5544                );
5545                continue;
5546            }
5547        }
5548        deleted += 1;
5549    }
5550
5551    // Purge expired run IDs from the registry in one pass.
5552    let expired_ids: std::collections::HashSet<&str> =
5553        expired.iter().map(|(id, _)| id.as_str()).collect();
5554    {
5555        let mut reg = state.registry.lock().await;
5556        reg.entries
5557            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5558        let _ = reg.save(&state.registry_path);
5559    }
5560
5561    Json(serde_json::json!({ "deleted": deleted })).into_response()
5562}
5563
5564/// Spawns the background auto-cleanup task. Returns a handle so the caller can
5565/// abort it when the policy is updated or disabled.
5566fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5567    tokio::spawn(async move {
5568        loop {
5569            let interval_secs = {
5570                let store = state.cleanup_policy.lock().await;
5571                match &store.policy {
5572                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5573                    _ => break,
5574                }
5575            };
5576            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5577            let n = run_auto_cleanup(&state).await;
5578            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5579        }
5580    })
5581}
5582
5583fn collect_runs_to_delete(
5584    reg: &ScanRegistry,
5585    max_age_days: Option<u32>,
5586    max_run_count: Option<u32>,
5587) -> std::collections::HashSet<String> {
5588    let mut to_delete = std::collections::HashSet::new();
5589    if let Some(days) = max_age_days {
5590        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5591        for e in &reg.entries {
5592            if e.timestamp_utc < cutoff {
5593                to_delete.insert(e.run_id.clone());
5594            }
5595        }
5596    }
5597    if let Some(max_count) = max_run_count {
5598        // entries are sorted newest-first; skip the ones we keep
5599        for e in reg.entries.iter().skip(max_count as usize) {
5600            to_delete.insert(e.run_id.clone());
5601        }
5602    }
5603    to_delete
5604}
5605
5606async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5607    let output_dir = {
5608        let mut cache = state.artifacts.lock().await;
5609        let d = cache.get(run_id).map(|a| a.output_dir.clone());
5610        cache.remove(run_id);
5611        d
5612    };
5613    let output_dir = if let Some(d) = output_dir {
5614        d
5615    } else {
5616        let reg = state.registry.lock().await;
5617        reg.find_by_run_id(run_id)
5618            .map(|e| recover_artifacts_from_registry(e).output_dir)
5619            .unwrap_or_default()
5620    };
5621    if output_dir.exists() {
5622        let _ = tokio::fs::remove_dir_all(&output_dir).await;
5623    }
5624}
5625
5626/// Core cleanup logic shared by the background task and the "Run Now" handler.
5627/// Applies both the age limit and the count limit, then updates `last_run_at`.
5628/// Returns the number of runs deleted.
5629async fn run_auto_cleanup(state: &AppState) -> u32 {
5630    let (max_age_days, max_run_count) = {
5631        let store = state.cleanup_policy.lock().await;
5632        match &store.policy {
5633            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5634            _ => return 0,
5635        }
5636    };
5637
5638    let to_delete = {
5639        let reg = state.registry.lock().await;
5640        collect_runs_to_delete(&reg, max_age_days, max_run_count)
5641    };
5642
5643    for run_id in &to_delete {
5644        delete_run_artifacts(state, run_id).await;
5645    }
5646
5647    // Purge from registry.
5648    if !to_delete.is_empty() {
5649        let mut reg = state.registry.lock().await;
5650        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5651        let _ = reg.save(&state.registry_path);
5652    }
5653
5654    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5655    {
5656        let mut store = state.cleanup_policy.lock().await;
5657        store.last_run_at = Some(chrono::Utc::now());
5658        store.last_run_deleted = Some(deleted);
5659        let _ = store.save(&state.cleanup_policy_path);
5660    }
5661    deleted
5662}
5663
5664// ── Auto-cleanup policy API ───────────────────────────────────────────────────
5665
5666/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
5667async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5668    let store = state.cleanup_policy.lock().await;
5669    Json(serde_json::json!({
5670        "policy": store.policy,
5671        "last_run_at": store.last_run_at,
5672        "last_run_deleted": store.last_run_deleted,
5673    }))
5674    .into_response()
5675}
5676
5677/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
5678async fn api_save_cleanup_policy(
5679    State(state): State<AppState>,
5680    Json(body): Json<CleanupPolicy>,
5681) -> Response {
5682    // Abort any running task so the new interval takes effect immediately.
5683    {
5684        let mut handle = state.cleanup_task_handle.lock().await;
5685        if let Some(h) = handle.take() {
5686            h.abort();
5687        }
5688    }
5689    {
5690        let mut store = state.cleanup_policy.lock().await;
5691        store.policy = Some(body.clone());
5692        if let Err(e) = store.save(&state.cleanup_policy_path) {
5693            return (
5694                StatusCode::INTERNAL_SERVER_ERROR,
5695                Json(serde_json::json!({"error": e.to_string()})),
5696            )
5697                .into_response();
5698        }
5699    }
5700    if body.enabled {
5701        let handle = spawn_cleanup_policy_task(state.clone());
5702        *state.cleanup_task_handle.lock().await = Some(handle);
5703    }
5704    StatusCode::NO_CONTENT.into_response()
5705}
5706
5707/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
5708async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5709    let deleted = run_auto_cleanup(&state).await;
5710    Json(serde_json::json!({ "deleted": deleted })).into_response()
5711}
5712
5713/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
5714async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5715    {
5716        let mut handle = state.cleanup_task_handle.lock().await;
5717        if let Some(h) = handle.take() {
5718            h.abort();
5719        }
5720    }
5721    {
5722        let mut store = state.cleanup_policy.lock().await;
5723        store.policy = None;
5724        let _ = store.save(&state.cleanup_policy_path);
5725    }
5726    StatusCode::NO_CONTENT.into_response()
5727}
5728
5729/// Serve the HTML artifact for a run — view or download.
5730/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
5731/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
5732/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
5733/// Only called for browser views; downloads keep the self-contained inline version.
5734fn swap_inline_chart_js_for_static(html: String) -> String {
5735    let Some(head_end) = html.find("</head>") else {
5736        return html;
5737    };
5738    let Some(script_start) = html[..head_end].rfind("<script") else {
5739        return html;
5740    };
5741    let Some(close_offset) = html[script_start..].find("</script>") else {
5742        return html;
5743    };
5744    let block_end = script_start + close_offset + "</script>".len();
5745    format!(
5746        "{}<script src=\"/static/chart-report.js\"></script>{}",
5747        &html[..script_start],
5748        &html[block_end..]
5749    )
5750}
5751
5752/// current-request Content-Security-Policy nonce check.
5753fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5754    // Find the first nonce value that was baked in at render time.
5755    let Some(start) = html.find("nonce=\"") else {
5756        // Reports generated before nonce support was added have bare <style> and <script>
5757        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
5758        // the inline blocks — without it the browser blocks all CSS and JS.
5759        return html
5760            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5761            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5762    };
5763    let value_start = start + 7; // len(r#"nonce=""#) == 7
5764    let Some(end_offset) = html[value_start..].find('"') else {
5765        return html.to_owned();
5766    };
5767    let old_nonce = &html[value_start..value_start + end_offset];
5768    html.replace(
5769        &format!("nonce=\"{old_nonce}\""),
5770        &format!("nonce=\"{new_nonce}\""),
5771    )
5772}
5773
5774fn serve_html_artifact(
5775    path: &Path,
5776    wants_download: bool,
5777    csp_nonce: &str,
5778    run_id: &str,
5779    server_mode: bool,
5780) -> Response {
5781    match fs::read_to_string(path) {
5782        Ok(raw) => {
5783            // Patch the saved nonce so inline styles/scripts pass CSP.
5784            let content = patch_html_nonce(&raw, csp_nonce);
5785            if wants_download {
5786                // Keep the self-contained inline version for downloads (opened as file://).
5787                (
5788                    [
5789                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5790                        (
5791                            header::CONTENT_DISPOSITION,
5792                            "attachment; filename=report.html",
5793                        ),
5794                    ],
5795                    content,
5796                )
5797                    .into_response()
5798            } else {
5799                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
5800                // browser caches it after the first view; the HTML response also shrinks.
5801                Html(swap_inline_chart_js_for_static(content)).into_response()
5802            }
5803        }
5804        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5805            let filename = path.file_name().map_or_else(
5806                || "report.html".to_string(),
5807                |n| n.to_string_lossy().into_owned(),
5808            );
5809            let html = LocateFileTemplate {
5810                run_id: run_id.to_owned(),
5811                artifact_type: "html".to_string(),
5812                expected_filename: filename,
5813                server_mode,
5814                csp_nonce: csp_nonce.to_owned(),
5815                version: env!("CARGO_PKG_VERSION"),
5816            }
5817            .render()
5818            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5819            (StatusCode::NOT_FOUND, Html(html)).into_response()
5820        }
5821        Err(err) => {
5822            let filename = path.file_name().map_or_else(
5823                || "report.html".to_string(),
5824                |n| n.to_string_lossy().into_owned(),
5825            );
5826            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5827            let html = ErrorTemplate {
5828                message: msg,
5829                last_report_url: Some("/view-reports".to_string()),
5830                last_report_label: Some("View Reports".to_string()),
5831                run_id: None,
5832                error_code: Some(404),
5833                csp_nonce: csp_nonce.to_owned(),
5834                version: env!("CARGO_PKG_VERSION"),
5835            }
5836            .render()
5837            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5838            (StatusCode::NOT_FOUND, Html(html)).into_response()
5839        }
5840    }
5841}
5842
5843/// Serve the PDF artifact for a run — inline or download.
5844fn serve_pdf_artifact(
5845    path: &Path,
5846    report_title: &str,
5847    run_id: &str,
5848    wants_download: bool,
5849    csp_nonce: &str,
5850) -> Response {
5851    match fs::read(path) {
5852        Ok(bytes) => {
5853            let filename = build_pdf_filename(report_title, run_id);
5854            let disposition = if wants_download {
5855                format!("attachment; filename=\"{filename}\"")
5856            } else {
5857                format!("inline; filename=\"{filename}\"")
5858            };
5859            (
5860                [
5861                    (header::CONTENT_TYPE, "application/pdf".to_string()),
5862                    (header::CONTENT_DISPOSITION, disposition),
5863                ],
5864                bytes,
5865            )
5866                .into_response()
5867        }
5868        Err(err) => {
5869            let filename = path.file_name().map_or_else(
5870                || "report.pdf".to_string(),
5871                |n| n.to_string_lossy().into_owned(),
5872            );
5873            let msg = format!(
5874                "PDF report '{filename}' could not be read.\n\n\
5875                 Error: {err}\n\n\
5876                 If you moved or renamed the output folder, the stored path is now stale. \
5877                 Use 'Open PDF folder' from the results page to browse the output directory."
5878            );
5879            let html = ErrorTemplate {
5880                message: msg,
5881                last_report_url: Some("/view-reports".to_string()),
5882                last_report_label: Some("View Reports".to_string()),
5883                run_id: Some(run_id.to_owned()),
5884                error_code: Some(404),
5885                csp_nonce: csp_nonce.to_owned(),
5886                version: env!("CARGO_PKG_VERSION"),
5887            }
5888            .render()
5889            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5890            (StatusCode::NOT_FOUND, Html(html)).into_response()
5891        }
5892    }
5893}
5894
5895/// Serve the JSON artifact for a run — view or download.
5896fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5897    match fs::read(path) {
5898        Ok(bytes) => {
5899            if wants_download {
5900                (
5901                    [
5902                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5903                        (
5904                            header::CONTENT_DISPOSITION,
5905                            "attachment; filename=result.json",
5906                        ),
5907                    ],
5908                    bytes,
5909                )
5910                    .into_response()
5911            } else {
5912                (
5913                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5914                    bytes,
5915                )
5916                    .into_response()
5917            }
5918        }
5919        Err(err) => {
5920            let filename = path.file_name().map_or_else(
5921                || "result.json".to_string(),
5922                |n| n.to_string_lossy().into_owned(),
5923            );
5924            let msg = format!(
5925                "JSON result '{filename}' could not be read.\n\n\
5926                 Error: {err}\n\n\
5927                 If you moved or renamed the output folder, the stored path is now stale. \
5928                 Use 'Open JSON folder' from the results page to browse the output directory."
5929            );
5930            let html = ErrorTemplate {
5931                message: msg,
5932                last_report_url: Some("/view-reports".to_string()),
5933                last_report_label: Some("View Reports".to_string()),
5934                run_id: None,
5935                error_code: Some(404),
5936                csp_nonce: csp_nonce.to_owned(),
5937                version: env!("CARGO_PKG_VERSION"),
5938            }
5939            .render()
5940            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5941            (StatusCode::NOT_FOUND, Html(html)).into_response()
5942        }
5943    }
5944}
5945
5946/// Recover a `RunArtifacts` from the persisted registry for a run ID.
5947fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5948    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
5949    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
5950    let output_dir = entry
5951        .html_path
5952        .as_ref()
5953        .or(entry.json_path.as_ref())
5954        .or(entry.pdf_path.as_ref())
5955        .or(entry.csv_path.as_ref())
5956        .or(entry.xlsx_path.as_ref())
5957        .and_then(|p| {
5958            let parent = p.parent()?;
5959            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5960            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
5961            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5962                parent.parent().map(PathBuf::from)
5963            } else {
5964                Some(parent.to_path_buf())
5965            }
5966        })
5967        .unwrap_or_default();
5968    // Recover pdf_path: use the persisted one, or look for report.pdf
5969    // adjacent to html/json if only the old entries lack it.
5970    let pdf_path = entry.pdf_path.clone().or_else(|| {
5971        let candidate = output_dir.join("report.pdf");
5972        candidate.exists().then_some(candidate)
5973    });
5974    // csv_path / xlsx_path: persisted paths take precedence; fall back to
5975    // scanning the run directory for files matching the expected patterns so
5976    // that runs created before this feature still surface their artifacts.
5977    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5978        // Check excel/ subfolder (new layout) then root (old layout).
5979        for dir in &[output_dir.join("excel"), output_dir.clone()] {
5980            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5981                entries
5982                    .filter_map(std::result::Result::ok)
5983                    .find(|e| {
5984                        let n = e.file_name();
5985                        let n = n.to_string_lossy();
5986                        n.starts_with("report_") && n.ends_with(ext)
5987                    })
5988                    .map(|e| e.path())
5989            }) {
5990                return Some(p);
5991            }
5992        }
5993        None
5994    };
5995
5996    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5997    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5998    RunArtifacts {
5999        output_dir: output_dir.clone(),
6000        html_path: entry.html_path.clone(),
6001        pdf_path,
6002        json_path: entry.json_path.clone(),
6003        csv_path,
6004        xlsx_path,
6005        scan_config_path: find_scan_config_in_dir(&output_dir),
6006        report_title: entry.project_label.clone(),
6007        result_context: RunResultContext::default(),
6008    }
6009}
6010
6011#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6012async fn resolve_artifact_set(
6013    state: &AppState,
6014    run_id: &str,
6015    csp_nonce: &str,
6016) -> Result<RunArtifacts, Response> {
6017    let cached = state.artifacts.lock().await.get(run_id).cloned();
6018    if let Some(a) = cached {
6019        return Ok(a);
6020    }
6021    let reg = state.registry.lock().await;
6022    if let Some(entry) = reg.find_by_run_id(run_id) {
6023        return Ok(recover_artifacts_from_registry(entry));
6024    }
6025    drop(reg);
6026    let short_id = &run_id[..run_id.len().min(8)];
6027    let hint = if matches!(
6028        run_id,
6029        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6030    ) {
6031        format!(
6032            " The URL format appears to be reversed — \
6033             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6034             Use the View Reports page to navigate to your scan."
6035        )
6036    } else {
6037        " The report may have been deleted or the report directory moved. \
6038         Use View Reports to browse your scan history."
6039            .to_string()
6040    };
6041    let error_html = ErrorTemplate {
6042        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6043        last_report_url: Some("/view-reports".to_string()),
6044        last_report_label: Some("View Reports".to_string()),
6045        run_id: None,
6046        error_code: Some(404),
6047        csp_nonce: csp_nonce.to_owned(),
6048        version: env!("CARGO_PKG_VERSION"),
6049    }
6050    .render()
6051    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6052    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6053}
6054
6055/// Return the path to a run's PDF, queuing background generation when it is missing.
6056///
6057/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6058/// Returns `Err(response)` when there is no JSON source to regenerate from.
6059async fn resolve_or_queue_pdf(
6060    state: &AppState,
6061    pdf_path: Option<PathBuf>,
6062    json_path: Option<PathBuf>,
6063    output_dir: PathBuf,
6064    run_id: &str,
6065    report_title: &str,
6066    csp_nonce: &str,
6067) -> Result<PathBuf, Response> {
6068    if let Some(p) = pdf_path {
6069        return Ok(p);
6070    }
6071    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6072        let msg = "PDF report was not generated for this run. \
6073                   Re-run the analysis with PDF output enabled."
6074            .to_string();
6075        let html = ErrorTemplate {
6076            message: msg,
6077            last_report_url: Some(format!("/runs/html/{run_id}")),
6078            last_report_label: Some("View HTML Report".to_string()),
6079            run_id: Some(run_id.to_string()),
6080            error_code: Some(404),
6081            csp_nonce: csp_nonce.to_string(),
6082            version: env!("CARGO_PKG_VERSION"),
6083        }
6084        .render()
6085        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6086        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6087    };
6088    let pdf_filename = build_pdf_filename(report_title, run_id);
6089    let pdf_dest = output_dir.join(&pdf_filename);
6090    if !pdf_dest.exists() {
6091        // Record the pending path so concurrent requests show the spinner.
6092        {
6093            let mut map = state.artifacts.lock().await;
6094            if let Some(entry) = map.get_mut(run_id) {
6095                entry.pdf_path = Some(pdf_dest.clone());
6096            }
6097        }
6098        {
6099            let mut reg = state.registry.lock().await;
6100            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6101                e.pdf_path = Some(pdf_dest.clone());
6102            }
6103            let _ = reg.save(&state.registry_path);
6104        }
6105        spawn_native_pdf_background(
6106            json_src,
6107            pdf_dest.clone(),
6108            run_id.to_string(),
6109            state.artifacts.clone(),
6110        );
6111    }
6112    Ok(pdf_dest)
6113}
6114
6115/// Self-refreshing "please wait" page shown while the background PDF task is still running.
6116fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6117    let html = format!(
6118                    "<!doctype html><html lang=\"en\"><head>\
6119                     <meta charset=utf-8>\
6120                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6121                     <meta http-equiv=\"refresh\" content=\"5\">\
6122                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
6123                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6124                     <style nonce=\"{csp_nonce}\">\
6125                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6126                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6127                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6128                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6129                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6130                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6131                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6132                     background:var(--bg);color:var(--text);}}\
6133                     .top-nav{{position:sticky;top:0;z-index:30;\
6134                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6135                     border-bottom:1px solid rgba(255,255,255,0.12);\
6136                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6137                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6138                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
6139                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6140                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6141                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6142                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6143                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6144                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6145                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6146                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6147                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6148                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6149                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6150                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6151                     justify-content:center;min-height:38px;border-radius:999px;\
6152                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6153                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6154                     .theme-toggle .icon-sun{{display:none;}}\
6155                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6156                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6157                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6158                     display:flex;align-items:center;justify-content:center;\
6159                     min-height:calc(100vh - 56px);}}\
6160                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6161                     .panel{{background:var(--surface);border:1px solid var(--line);\
6162                     border-radius:var(--radius);box-shadow:var(--shadow);\
6163                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6164                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
6165                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
6166                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6167                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6168                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6169                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6170                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6171                     min-height:42px;padding:0 20px;border-radius:14px;\
6172                     border:1px solid var(--line-strong);text-decoration:none;\
6173                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6174                     .back-link:hover{{background:var(--line);}}\
6175                     </style></head>\
6176                     <body>\
6177                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6178                       <a class=\"brand\" href=\"/\">\
6179                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6180                         <div class=\"brand-copy\">\
6181                           <div class=\"brand-title\">OxideSLOC</div>\
6182                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6183                         </div>\
6184                       </a>\
6185                       <div class=\"nav-right\">\
6186                         <a class=\"nav-pill\" href=\"/\">Home</a>\
6187                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6188                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6189                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6190                           <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>\
6191                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6192                           <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>\
6193                         </button>\
6194                       </div>\
6195                     </div></div>\
6196                     <div class=\"page\"><div class=\"panel\">\
6197                       <div class=\"spin-ring\"></div>\
6198                       <h1>Generating PDF\u{2026}</h1>\
6199                       <p>The PDF is being generated from the scan results.<br>\
6200                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
6201                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6202                     </div></div>\
6203                     <script nonce=\"{csp_nonce}\">\
6204                     (function(){{\
6205                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6206                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
6207                       var t=document.getElementById(\"theme-toggle\");\
6208                       if(t)t.addEventListener(\"click\",function(){{\
6209                         var d=b.classList.toggle(\"dark-theme\");\
6210                         localStorage.setItem(k,d?\"dark\":\"light\");\
6211                       }});\
6212                     }})();\
6213                     </script>\
6214                     </body></html>"
6215    );
6216    Html(html).into_response()
6217}
6218
6219/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
6220fn render_error_artifact_html(
6221    message: String,
6222    last_report_url: Option<String>,
6223    last_report_label: Option<String>,
6224    run_id: Option<String>,
6225    error_code: Option<u16>,
6226    csp_nonce: &str,
6227) -> String {
6228    ErrorTemplate {
6229        message,
6230        last_report_url,
6231        last_report_label,
6232        run_id,
6233        error_code,
6234        csp_nonce: csp_nonce.to_owned(),
6235        version: env!("CARGO_PKG_VERSION"),
6236    }
6237    .render()
6238    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
6239}
6240
6241/// Read a file and serve it as an attachment download.
6242fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
6243    fs::read(path).map_or_else(
6244        |_| StatusCode::NOT_FOUND.into_response(),
6245        |bytes| {
6246            let filename = path.file_name().map_or_else(
6247                || fallback_filename.to_string(),
6248                |n| n.to_string_lossy().into_owned(),
6249            );
6250            (
6251                [
6252                    (header::CONTENT_TYPE, content_type.to_string()),
6253                    (
6254                        header::CONTENT_DISPOSITION,
6255                        format!("attachment; filename=\"{filename}\""),
6256                    ),
6257                ],
6258                bytes,
6259            )
6260                .into_response()
6261        },
6262    )
6263}
6264
6265fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6266    let Some(path) = csv_path else {
6267        let html = render_error_artifact_html(
6268            "CSV report was not generated for this run, or was not recorded in \
6269             the scan registry."
6270                .to_string(),
6271            Some(format!("/runs/html/{run_id}")),
6272            Some("View HTML Report".to_string()),
6273            Some(run_id.to_string()),
6274            Some(404),
6275            csp_nonce,
6276        );
6277        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6278    };
6279    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6280}
6281
6282fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6283    let Some(path) = xlsx_path else {
6284        let html = render_error_artifact_html(
6285            "Excel report was not generated for this run, or was not recorded in \
6286             the scan registry."
6287                .to_string(),
6288            Some(format!("/runs/html/{run_id}")),
6289            Some("View HTML Report".to_string()),
6290            Some(run_id.to_string()),
6291            Some(404),
6292            csp_nonce,
6293        );
6294        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6295    };
6296    serve_binary_download(
6297        &path,
6298        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6299        "report.xlsx",
6300    )
6301}
6302
6303fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6304    let path = artifact_set
6305        .scan_config_path
6306        .as_deref()
6307        .map(std::path::Path::to_path_buf)
6308        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6309        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6310    fs::read(&path).map_or_else(
6311        |_| StatusCode::NOT_FOUND.into_response(),
6312        |bytes| {
6313            (
6314                [
6315                    (
6316                        header::CONTENT_TYPE,
6317                        "application/json; charset=utf-8".to_string(),
6318                    ),
6319                    (
6320                        header::CONTENT_DISPOSITION,
6321                        "attachment; filename=\"scan-config.json\"".to_string(),
6322                    ),
6323                ],
6324                bytes,
6325            )
6326                .into_response()
6327        },
6328    )
6329}
6330
6331/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
6332/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
6333/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
6334/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
6335async fn serve_submodule_pdf_arm(
6336    artifact: &str,
6337    artifact_set: RunArtifacts,
6338    wants_download: bool,
6339    run_id: &str,
6340    csp_nonce: &str,
6341) -> Response {
6342    // "sub_benchmark_pdf" → base = "sub_benchmark"
6343    let base = artifact.trim_end_matches("_pdf");
6344    let sub_dir = artifact_set.output_dir.join("submodules");
6345    let pdf_path = sub_dir.join(format!("{base}.pdf"));
6346
6347    if !pdf_path.exists() {
6348        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
6349        let derived_safe = base.trim_start_matches("sub_");
6350        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6351            let parent_run = read_json(jp).ok()?;
6352            let sub = parent_run
6353                .submodule_summaries
6354                .iter()
6355                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6356                .clone();
6357            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6358            Some((parent_run, sub, parent_path))
6359        });
6360
6361        if let Some((parent_run, sub, parent_path)) = rebuilt {
6362            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6363            let pp = pdf_path.clone();
6364            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6365        }
6366    }
6367
6368    if !pdf_path.exists() {
6369        let html = render_error_artifact_html(
6370            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6371             enabled."
6372                .to_string(),
6373            Some("/view-reports".to_string()),
6374            Some("View Reports".to_string()),
6375            Some(run_id.to_string()),
6376            Some(404),
6377            csp_nonce,
6378        );
6379        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6380    }
6381
6382    serve_pdf_artifact(
6383        &pdf_path,
6384        &artifact_set.report_title,
6385        run_id,
6386        wants_download,
6387        csp_nonce,
6388    )
6389}
6390
6391fn serve_submodule_arm(
6392    artifact: &str,
6393    artifact_set: &RunArtifacts,
6394    wants_download: bool,
6395    csp_nonce: &str,
6396    run_id: &str,
6397    server_mode: bool,
6398) -> Response {
6399    if artifact.len() > 128
6400        || !artifact
6401            .chars()
6402            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6403    {
6404        return StatusCode::BAD_REQUEST.into_response();
6405    }
6406    let filename = format!("{artifact}.html");
6407    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
6408    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6409    let path = if new_layout.exists() {
6410        new_layout
6411    } else {
6412        artifact_set.output_dir.join(&filename)
6413    };
6414    if !path.exists() {
6415        let html = render_error_artifact_html(
6416            format!(
6417                "Sub-report '{artifact}' was not found in the run directory.\n\
6418                 Re-run the analysis with 'Detect and separate git submodules' \
6419                 and HTML output enabled."
6420            ),
6421            Some("/view-reports".to_string()),
6422            Some("View Reports".to_string()),
6423            Some(run_id.to_string()),
6424            Some(404),
6425            csp_nonce,
6426        );
6427        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6428    }
6429    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6430}
6431
6432async fn serve_pdf_arm(
6433    state: &AppState,
6434    artifact_set: RunArtifacts,
6435    wants_download: bool,
6436    run_id: &str,
6437    csp_nonce: &str,
6438) -> Response {
6439    let report_title = artifact_set.report_title.clone();
6440    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6441    let stale_html_name = artifact_set
6442        .html_path
6443        .as_deref()
6444        .and_then(|p| p.file_name())
6445        .map(|n| n.to_string_lossy().into_owned());
6446    let path = match resolve_or_queue_pdf(
6447        state,
6448        artifact_set.pdf_path,
6449        artifact_set.json_path.clone(),
6450        artifact_set.output_dir.clone(),
6451        run_id,
6452        &report_title,
6453        csp_nonce,
6454    )
6455    .await
6456    {
6457        Ok(p) => p,
6458        Err(r) => return r,
6459    };
6460    if !path.exists() {
6461        // Distinguish a stale registry path (folder moved) from an in-progress
6462        // background generation. Only show the locate page when the PDF was
6463        // already recorded in the registry but the file is now missing.
6464        if had_pdf_in_registry {
6465            if let Some(expected_filename) = stale_html_name {
6466                let html = LocateFileTemplate {
6467                    run_id: run_id.to_string(),
6468                    artifact_type: "pdf".to_string(),
6469                    expected_filename,
6470                    server_mode: state.server_mode,
6471                    csp_nonce: csp_nonce.to_string(),
6472                    version: env!("CARGO_PKG_VERSION"),
6473                }
6474                .render()
6475                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6476                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6477            }
6478        }
6479        return pdf_generating_response(run_id, csp_nonce);
6480    }
6481    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6482}
6483
6484async fn artifact_handler(
6485    State(state): State<AppState>,
6486    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6487    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6488    Query(query): Query<ArtifactQuery>,
6489) -> Response {
6490    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6491        Ok(a) => a,
6492        Err(r) => return r,
6493    };
6494
6495    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6496
6497    match artifact.as_str() {
6498        "html" => {
6499            let Some(path) = artifact_set.html_path else {
6500                return StatusCode::NOT_FOUND.into_response();
6501            };
6502            serve_html_artifact(
6503                &path,
6504                wants_download,
6505                &csp_nonce,
6506                &run_id,
6507                state.server_mode,
6508            )
6509        }
6510        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6511        "json" => {
6512            let Some(path) = artifact_set.json_path else {
6513                let html = render_error_artifact_html(
6514                    "JSON result was not generated for this run, or was not recorded in \
6515                     the scan registry. Re-run the analysis with JSON output enabled."
6516                        .to_string(),
6517                    Some("/view-reports".to_string()),
6518                    Some("View Reports".to_string()),
6519                    Some(run_id.clone()),
6520                    Some(404),
6521                    &csp_nonce,
6522                );
6523                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6524            };
6525            serve_json_artifact(&path, wants_download, &csp_nonce)
6526        }
6527        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6528        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6529        "scan-config" => serve_scan_config_arm(&artifact_set),
6530        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6531            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6532                .await
6533        }
6534        _ if artifact.starts_with("sub_") => serve_submodule_arm(
6535            &artifact,
6536            &artifact_set,
6537            wants_download,
6538            &csp_nonce,
6539            &run_id,
6540            state.server_mode,
6541        ),
6542        _ => StatusCode::NOT_FOUND.into_response(),
6543    }
6544}
6545
6546// ── History ───────────────────────────────────────────────────────────────────
6547
6548struct SubmoduleLinkRow {
6549    name: String,
6550    url: String,
6551}
6552
6553struct HistoryEntryRow {
6554    run_id: String,
6555    run_id_short: String,
6556    timestamp: String,
6557    timestamp_utc_ms: i64,
6558    project_label: String,
6559    project_path: String,
6560    files_analyzed: u64,
6561    files_skipped: u64,
6562    code_lines: u64,
6563    comment_lines: u64,
6564    blank_lines: u64,
6565    git_branch: String,
6566    git_commit: String,
6567    has_html: bool,
6568    has_json: bool,
6569    has_pdf: bool,
6570    submodule_links: Vec<SubmoduleLinkRow>,
6571    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
6572    submodule_names_csv: String,
6573}
6574
6575/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
6576fn nth_weekday_of_month(
6577    year: i32,
6578    month: u32,
6579    weekday: chrono::Weekday,
6580    n: u32,
6581) -> chrono::NaiveDate {
6582    use chrono::Datelike;
6583    let mut count = 0u32;
6584    let mut day = 1u32;
6585    loop {
6586        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6587        if d.weekday() == weekday {
6588            count += 1;
6589            if count == n {
6590                return d;
6591            }
6592        }
6593        day += 1;
6594    }
6595}
6596
6597/// Returns true if `dt` falls within US Pacific Daylight Time.
6598/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
6599/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
6600fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6601    use chrono::{Datelike, TimeZone};
6602    let year = dt.year();
6603    let dst_start = chrono::Utc.from_utc_datetime(
6604        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6605            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6606    );
6607    let dst_end = chrono::Utc.from_utc_datetime(
6608        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6609            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6610    );
6611    dt >= dst_start && dt < dst_end
6612}
6613
6614fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6615    if is_pacific_dst(dt) {
6616        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6617            .format("%Y-%m-%d %H:%M PDT")
6618            .to_string()
6619    } else {
6620        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6621            .format("%Y-%m-%d %H:%M PST")
6622            .to_string()
6623    }
6624}
6625
6626/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
6627fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6628    let (offset, tz) = if is_pacific_dst(dt) {
6629        (
6630            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6631            "PDT",
6632        )
6633    } else {
6634        (
6635            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6636            "PST",
6637        )
6638    };
6639    format!(
6640        "{} {tz}",
6641        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6642    )
6643}
6644
6645fn fmt_git_date(iso: &str) -> Option<String> {
6646    chrono::DateTime::parse_from_rfc3339(iso)
6647        .ok()
6648        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6649}
6650
6651fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6652    reg.entries
6653        .iter()
6654        .map(|e| {
6655            let submodule_links = {
6656                let mut links: Vec<SubmoduleLinkRow> = vec![];
6657                let sub_dir = e
6658                    .html_path
6659                    .as_ref()
6660                    .and_then(|p| p.parent())
6661                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6662                if let Some(dir) = sub_dir {
6663                    if let Ok(rd) = std::fs::read_dir(dir) {
6664                        for entry_res in rd.flatten() {
6665                            let fname = entry_res.file_name();
6666                            let fname_str = fname.to_string_lossy();
6667                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6668                                let stem = &fname_str[..fname_str.len() - 5];
6669                                let display = stem[4..].replace('-', " ");
6670                                links.push(SubmoduleLinkRow {
6671                                    name: display,
6672                                    url: format!("/runs/{stem}/{}", e.run_id),
6673                                });
6674                            }
6675                        }
6676                    }
6677                }
6678                links.sort_by(|a, b| a.name.cmp(&b.name));
6679                links
6680            };
6681            let submodule_names_csv = submodule_links
6682                .iter()
6683                .map(|l| l.name.as_str())
6684                .collect::<Vec<_>>()
6685                .join(",");
6686            HistoryEntryRow {
6687                run_id: e.run_id.clone(),
6688                run_id_short: e
6689                    .run_id
6690                    .split('-')
6691                    .next_back()
6692                    .unwrap_or(&e.run_id)
6693                    .chars()
6694                    .take(7)
6695                    .collect(),
6696                timestamp: fmt_la_time(e.timestamp_utc),
6697                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6698                project_label: e.project_label.clone(),
6699                project_path: e
6700                    .input_roots
6701                    .first()
6702                    .map(|s| sanitize_path_str(s))
6703                    .unwrap_or_default(),
6704                files_analyzed: e.summary.files_analyzed,
6705                files_skipped: e.summary.files_skipped,
6706                code_lines: e.summary.code_lines,
6707                comment_lines: e.summary.comment_lines,
6708                blank_lines: e.summary.blank_lines,
6709                git_branch: e.git_branch.clone().unwrap_or_default(),
6710                git_commit: e.git_commit.clone().unwrap_or_default(),
6711                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6712                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6713                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6714                submodule_links,
6715                submodule_names_csv,
6716            }
6717        })
6718        .collect()
6719}
6720
6721#[derive(Deserialize, Default)]
6722struct HistoryQuery {
6723    linked: Option<String>,
6724    error: Option<String>,
6725}
6726
6727async fn history_handler(
6728    State(state): State<AppState>,
6729    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6730    Query(query): Query<HistoryQuery>,
6731) -> impl IntoResponse {
6732    // Auto-scan all watched directories before rendering so the list stays fresh.
6733    auto_scan_watched_dirs(&state).await;
6734    let watched_dirs: Vec<String> = {
6735        let wd = state.watched_dirs.lock().await;
6736        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6737    };
6738    let mut entries = {
6739        let reg = state.registry.lock().await;
6740        make_history_rows(&reg)
6741    };
6742    entries.retain(|e| e.has_html);
6743    let total_scans = entries.len();
6744    let linked_count = query
6745        .linked
6746        .as_deref()
6747        .and_then(|s| s.parse::<usize>().ok())
6748        .unwrap_or(0);
6749    let browse_error = query.error.filter(|s| !s.is_empty());
6750    let template = HistoryTemplate {
6751        version: env!("CARGO_PKG_VERSION"),
6752        entries,
6753        total_scans,
6754        linked_count,
6755        browse_error,
6756        watched_dirs,
6757        csp_nonce,
6758        server_mode: state.server_mode,
6759    };
6760    Html(
6761        template
6762            .render()
6763            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6764    )
6765    .into_response()
6766}
6767
6768async fn compare_select_handler(
6769    State(state): State<AppState>,
6770    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6771) -> impl IntoResponse {
6772    auto_scan_watched_dirs(&state).await;
6773    let watched_dirs: Vec<String> = {
6774        let wd = state.watched_dirs.lock().await;
6775        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6776    };
6777    let mut entries = {
6778        let reg = state.registry.lock().await;
6779        make_history_rows(&reg)
6780    };
6781    entries.retain(|e| e.has_json);
6782    let total_scans = entries.len();
6783    let template = CompareSelectTemplate {
6784        version: env!("CARGO_PKG_VERSION"),
6785        entries,
6786        total_scans,
6787        watched_dirs,
6788        csp_nonce,
6789        server_mode: state.server_mode,
6790    };
6791    Html(
6792        template
6793            .render()
6794            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6795    )
6796    .into_response()
6797}
6798
6799// ── Compare ───────────────────────────────────────────────────────────────────
6800
6801#[derive(Deserialize, Default)]
6802struct CompareQuery {
6803    a: Option<String>,
6804    b: Option<String>,
6805    /// Optional submodule name to scope the comparison to one submodule.
6806    sub: Option<String>,
6807    /// "super" to exclude all submodule files and show only the super-repo.
6808    scope: Option<String>,
6809}
6810
6811struct CompareFileDeltaRow {
6812    relative_path: String,
6813    language: String,
6814    status: String,
6815    baseline_code: i64,
6816    current_code: i64,
6817    baseline_code_display: String,
6818    current_code_display: String,
6819    code_delta_str: String,
6820    code_delta_class: String,
6821    comment_delta_str: String,
6822    comment_delta_class: String,
6823    total_delta_str: String,
6824    total_delta_class: String,
6825}
6826
6827/// Recompute `summary_totals` from the current `per_file_records` slice.
6828/// Used when `per_file_records` has been narrowed to a submodule subset.
6829fn recompute_summary_from_records(run: &mut AnalysisRun) {
6830    let mut totals = SummaryTotals::default();
6831    for r in &run.per_file_records {
6832        if r.language.is_some() {
6833            totals.files_analyzed += 1;
6834        }
6835        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6836        totals.code_lines += r.effective_counts.code_lines;
6837        totals.comment_lines += r.effective_counts.comment_lines;
6838        totals.blank_lines += r.effective_counts.blank_lines;
6839        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6840        totals.functions += r.raw_line_categories.functions;
6841        totals.classes += r.raw_line_categories.classes;
6842        totals.variables += r.raw_line_categories.variables;
6843        totals.imports += r.raw_line_categories.imports;
6844        totals.test_count += r.raw_line_categories.test_count;
6845        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6846        totals.test_suite_count += r.raw_line_categories.test_suite_count;
6847        if let Some(cov) = &r.coverage {
6848            totals.coverage_lines_found += u64::from(cov.lines_found);
6849            totals.coverage_lines_hit += u64::from(cov.lines_hit);
6850            totals.coverage_functions_found += u64::from(cov.functions_found);
6851            totals.coverage_functions_hit += u64::from(cov.functions_hit);
6852            totals.coverage_branches_found += u64::from(cov.branches_found);
6853            totals.coverage_branches_hit += u64::from(cov.branches_hit);
6854        }
6855    }
6856    totals.files_considered = totals.files_analyzed;
6857    run.summary_totals = totals;
6858}
6859
6860fn fmt_delta(n: i64) -> String {
6861    if n > 0 {
6862        format!("+{n}")
6863    } else {
6864        format!("{n}")
6865    }
6866}
6867
6868fn delta_class(n: i64) -> &'static str {
6869    use std::cmp::Ordering;
6870    match n.cmp(&0) {
6871        Ordering::Greater => "pos",
6872        Ordering::Less => "neg",
6873        Ordering::Equal => "zero",
6874    }
6875}
6876
6877// ratio/percentage display, precision loss acceptable
6878#[allow(clippy::cast_precision_loss)]
6879fn fmt_pct(delta: i64, baseline: u64) -> String {
6880    if baseline == 0 {
6881        return "—".to_string();
6882    }
6883    #[allow(clippy::cast_precision_loss)]
6884    let pct = (delta as f64 / baseline as f64) * 100.0;
6885    if pct > 0.049 {
6886        format!("+{pct:.1}%")
6887    } else if pct < -0.049 {
6888        format!("{pct:.1}%")
6889    } else {
6890        "±0%".to_string()
6891    }
6892}
6893
6894/// Returns (`display_string`, `css_class`) for a numeric change column cell.
6895fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6896    prev.map_or_else(
6897        || ("—".to_string(), "na"),
6898        |p| {
6899            #[allow(clippy::cast_possible_wrap)]
6900            let d = curr as i64 - p as i64;
6901            (fmt_delta(d), delta_class(d))
6902        },
6903    )
6904}
6905
6906#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
6907fn load_scan_for_compare(
6908    json_path: &std::path::Path,
6909    scan_label: &str,
6910    run_id: &str,
6911    server_mode: bool,
6912    compare_url: &str,
6913    csp_nonce: &str,
6914) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6915    match read_json(json_path) {
6916        Ok(r) => Ok(r),
6917        Err(e) => {
6918            if server_mode {
6919                let html = ErrorTemplate {
6920                    message: format!(
6921                        "Could not load {scan_label} scan data. The scan output folder may have \
6922                         been moved, renamed, or deleted. Re-running the analysis will create \
6923                         fresh comparison data."
6924                    ),
6925                    last_report_url: Some("/compare-scans".to_string()),
6926                    last_report_label: Some("Compare Scans".to_string()),
6927                    run_id: Some(run_id.to_owned()),
6928                    error_code: Some(404),
6929                    csp_nonce: csp_nonce.to_owned(),
6930                    version: env!("CARGO_PKG_VERSION"),
6931                }
6932                .render()
6933                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6934                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6935            }
6936            let msg = format!(
6937                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6938                json_path.display()
6939            );
6940            let folder_hint = json_path
6941                .parent()
6942                .map(|p| p.display().to_string())
6943                .unwrap_or_default();
6944            Err(missing_scan_relocate_response(
6945                &msg,
6946                run_id,
6947                &folder_hint,
6948                compare_url,
6949                false,
6950                csp_nonce,
6951            ))
6952        }
6953    }
6954}
6955
6956struct ChurnStats {
6957    new_scope: bool,
6958    scope_flag: bool,
6959    churn_rate_str: String,
6960    churn_rate_class: String,
6961}
6962
6963fn compute_churn_stats(
6964    baseline_code: u64,
6965    current_code: u64,
6966    lines_added: i64,
6967    lines_removed: i64,
6968) -> ChurnStats {
6969    let new_scope = baseline_code == 0 && current_code > 0;
6970    #[allow(clippy::cast_precision_loss)]
6971    let churn_pct = if baseline_code > 0 {
6972        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6973    } else {
6974        0.0
6975    };
6976    #[allow(clippy::cast_precision_loss)]
6977    let scope_flag =
6978        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6979    let churn_rate_str = if new_scope {
6980        "New".to_string()
6981    } else if baseline_code > 0 {
6982        format!("{churn_pct:.1}%")
6983    } else {
6984        "—".to_string()
6985    };
6986    let churn_rate_class = if new_scope || churn_pct > 20.0 {
6987        "high".to_string()
6988    } else if churn_pct > 5.0 {
6989        "med".to_string()
6990    } else {
6991        "low".to_string()
6992    };
6993    ChurnStats {
6994        new_scope,
6995        scope_flag,
6996        churn_rate_str,
6997        churn_rate_class,
6998    }
6999}
7000
7001/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7002/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7003/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7004fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7005    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7006    if !has_data {
7007        return String::new();
7008    }
7009    let base_str = s
7010        .baseline_coverage_line_pct
7011        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7012    let curr_str = s
7013        .current_coverage_line_pct
7014        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7015    let (delta_str, cls) = match s.coverage_line_pct_delta {
7016        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7017        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7018        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7019        None => ("\u{2014}".into(), "zero"),
7020    };
7021    format!(
7022        r#"<div class="delta-card">
7023          <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>
7024          <div class="delta-card-label">Line coverage</div>
7025          <div class="delta-card-from">Before: {base_str}</div>
7026          <div class="delta-card-to">{curr_str}</div>
7027          <span class="delta-card-change {cls}">{delta_str}</span>
7028        </div>"#
7029    )
7030}
7031
7032/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
7033#[allow(clippy::ref_option)]
7034fn narrow_run_pair_by_scope(
7035    mut baseline: AnalysisRun,
7036    mut current: AnalysisRun,
7037    active_sub: &Option<String>,
7038    super_scope: bool,
7039) -> (AnalysisRun, AnalysisRun) {
7040    if let Some(ref sub_name) = active_sub {
7041        baseline
7042            .per_file_records
7043            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7044        current
7045            .per_file_records
7046            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7047        recompute_summary_from_records(&mut baseline);
7048        recompute_summary_from_records(&mut current);
7049    } else if super_scope {
7050        baseline.per_file_records.retain(|f| f.submodule.is_none());
7051        current.per_file_records.retain(|f| f.submodule.is_none());
7052        recompute_summary_from_records(&mut baseline);
7053        recompute_summary_from_records(&mut current);
7054    }
7055    (baseline, current)
7056}
7057
7058/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
7059#[allow(clippy::ref_option)]
7060fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7061    if let Some(ref sub_name) = active_sub {
7062        for run in runs.iter_mut() {
7063            run.per_file_records
7064                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7065            recompute_summary_from_records(run);
7066        }
7067    } else if super_scope {
7068        for run in runs.iter_mut() {
7069            run.per_file_records.retain(|f| f.submodule.is_none());
7070            recompute_summary_from_records(run);
7071        }
7072    }
7073}
7074
7075#[allow(clippy::too_many_lines)]
7076async fn compare_handler(
7077    State(state): State<AppState>,
7078    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7079    Query(query): Query<CompareQuery>,
7080) -> impl IntoResponse {
7081    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
7082    // redirect to the history page where the user can select two runs.
7083    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7084        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7085        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7086    };
7087
7088    let (maybe_a, maybe_b) = {
7089        let reg = state.registry.lock().await;
7090        (
7091            reg.find_by_run_id(&run_id_a).cloned(),
7092            reg.find_by_run_id(&run_id_b).cloned(),
7093        )
7094    };
7095
7096    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7097        let html = ErrorTemplate {
7098            message: "One or both run IDs were not found in scan history. \
7099                      The runs may have been deleted or the registry may have been reset."
7100                .to_string(),
7101            last_report_url: Some("/compare-scans".to_string()),
7102            last_report_label: Some("Compare Scans".to_string()),
7103            run_id: None,
7104            error_code: None,
7105            csp_nonce: csp_nonce.clone(),
7106            version: env!("CARGO_PKG_VERSION"),
7107        }
7108        .render()
7109        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7110        return Html(html).into_response();
7111    };
7112
7113    // Ensure older scan is always the baseline.
7114    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7115        (entry_a, entry_b)
7116    } else {
7117        (entry_b, entry_a)
7118    };
7119
7120    // If query params were in the wrong order, redirect to canonical URL so the
7121    // browser always shows the same URL for the same two scans regardless of how
7122    // the user arrived here (Full diff button vs. Compare Scans selection).
7123    if baseline_entry.run_id != run_id_a {
7124        let canonical = format!(
7125            "/compare?a={}&b={}",
7126            baseline_entry.run_id, current_entry.run_id
7127        );
7128        return axum::response::Redirect::to(&canonical).into_response();
7129    }
7130
7131    let (Some(base_json), Some(curr_json)) = (
7132        baseline_entry.json_path.as_ref(),
7133        current_entry.json_path.as_ref(),
7134    ) else {
7135        let html = ErrorTemplate {
7136            message: "Full comparison requires JSON scan data, which was not saved for one or \
7137                      both of these runs. JSON is now always saved for new scans — re-run the \
7138                      affected projects to enable comparisons."
7139                .to_string(),
7140            last_report_url: Some("/compare-scans".to_string()),
7141            last_report_label: Some("Compare Scans".to_string()),
7142            run_id: None,
7143            error_code: None,
7144            csp_nonce: csp_nonce.clone(),
7145            version: env!("CARGO_PKG_VERSION"),
7146        }
7147        .render()
7148        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7149        return Html(html).into_response();
7150    };
7151
7152    let compare_url = format!(
7153        "/compare?a={}&b={}",
7154        baseline_entry.run_id, current_entry.run_id
7155    );
7156
7157    let baseline_run = match load_scan_for_compare(
7158        base_json,
7159        "baseline",
7160        &baseline_entry.run_id,
7161        state.server_mode,
7162        &compare_url,
7163        &csp_nonce,
7164    ) {
7165        Ok(r) => r,
7166        Err(resp) => return resp,
7167    };
7168    let current_run = match load_scan_for_compare(
7169        curr_json,
7170        "current",
7171        &current_entry.run_id,
7172        state.server_mode,
7173        &compare_url,
7174        &csp_nonce,
7175    ) {
7176        Ok(r) => r,
7177        Err(resp) => return resp,
7178    };
7179
7180    let active_submodule = query.sub.clone();
7181    let super_scope_active = query.scope.as_deref() == Some("super");
7182
7183    let submodule_options = baseline_run
7184        .submodule_summaries
7185        .iter()
7186        .chain(current_run.submodule_summaries.iter())
7187        .map(|s| s.name.clone())
7188        .collect::<std::collections::BTreeSet<_>>()
7189        .into_iter()
7190        .collect::<Vec<_>>();
7191    let has_any_submodule_data = !submodule_options.is_empty();
7192
7193    // Narrow per_file_records when a scope is active, then recompute totals.
7194    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
7195        baseline_run,
7196        current_run,
7197        &active_submodule,
7198        super_scope_active,
7199    );
7200
7201    let comparison = compute_delta(&effective_baseline, &effective_current);
7202
7203    let file_rows: Vec<CompareFileDeltaRow> = comparison
7204        .file_deltas
7205        .iter()
7206        .map(|d| CompareFileDeltaRow {
7207            relative_path: d.relative_path.clone(),
7208            language: d.language.clone().unwrap_or_else(|| "—".into()),
7209            status: match d.status {
7210                FileChangeStatus::Added => "added".into(),
7211                FileChangeStatus::Removed => "removed".into(),
7212                FileChangeStatus::Modified => "modified".into(),
7213                FileChangeStatus::Unchanged => "unchanged".into(),
7214            },
7215            baseline_code: d.baseline_code,
7216            current_code: d.current_code,
7217            baseline_code_display: if d.status == FileChangeStatus::Added {
7218                "—".into()
7219            } else {
7220                d.baseline_code.to_string()
7221            },
7222            current_code_display: if d.status == FileChangeStatus::Removed {
7223                "—".into()
7224            } else {
7225                d.current_code.to_string()
7226            },
7227            code_delta_str: fmt_delta(d.code_delta),
7228            code_delta_class: delta_class(d.code_delta).into(),
7229            comment_delta_str: fmt_delta(d.comment_delta),
7230            comment_delta_class: delta_class(d.comment_delta).into(),
7231            total_delta_str: fmt_delta(d.total_delta),
7232            total_delta_class: delta_class(d.total_delta).into(),
7233        })
7234        .collect();
7235
7236    let project_path = baseline_entry
7237        .input_roots
7238        .first()
7239        .map(|s| sanitize_path_str(s))
7240        .unwrap_or_default();
7241    let lines_added = sum_added_code_lines(&comparison);
7242    let lines_removed = sum_removed_code_lines(&comparison);
7243    let churn = compute_churn_stats(
7244        comparison.summary.baseline_code,
7245        comparison.summary.current_code,
7246        lines_added,
7247        lines_removed,
7248    );
7249    let s = &comparison.summary;
7250    let template = CompareTemplate {
7251        version: env!("CARGO_PKG_VERSION"),
7252        project_label: baseline_entry.project_label.clone(),
7253        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
7254        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
7255        baseline_run_id: baseline_entry.run_id.clone(),
7256        current_run_id: current_entry.run_id.clone(),
7257        baseline_run_id_short: baseline_entry
7258            .run_id
7259            .split('-')
7260            .next_back()
7261            .unwrap_or(&baseline_entry.run_id)
7262            .chars()
7263            .take(7)
7264            .collect(),
7265        current_run_id_short: current_entry
7266            .run_id
7267            .split('-')
7268            .next_back()
7269            .unwrap_or(&current_entry.run_id)
7270            .chars()
7271            .take(7)
7272            .collect(),
7273        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
7274        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
7275        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
7276        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
7277        project_path: project_path.clone(),
7278        baseline_code: s.baseline_code,
7279        current_code: s.current_code,
7280        code_lines_delta_str: fmt_delta(s.code_lines_delta),
7281        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
7282        baseline_files: s.baseline_files,
7283        current_files: s.current_files,
7284        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
7285        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
7286        baseline_comments: s.baseline_comments,
7287        current_comments: s.current_comments,
7288        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
7289        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
7290        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
7291        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
7292        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
7293        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
7294        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
7295        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
7296        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
7297        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
7298        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
7299        code_lines_added: lines_added,
7300        code_lines_removed: lines_removed,
7301        new_scope: churn.new_scope,
7302        churn_rate_str: churn.churn_rate_str,
7303        churn_rate_class: churn.churn_rate_class,
7304        scope_flag: churn.scope_flag,
7305        files_added: comparison.files_added,
7306        files_removed: comparison.files_removed,
7307        files_modified: comparison.files_modified,
7308        files_unchanged: comparison.files_unchanged,
7309        file_rows,
7310        baseline_git_author: baseline_entry.git_author.clone(),
7311        current_git_author: current_entry.git_author.clone(),
7312        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
7313        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
7314        baseline_git_tags: baseline_entry.git_tags.clone(),
7315        current_git_tags: current_entry.git_tags.clone(),
7316        baseline_git_commit_date: baseline_entry
7317            .git_commit_date
7318            .as_deref()
7319            .and_then(fmt_git_date),
7320        current_git_commit_date: current_entry
7321            .git_commit_date
7322            .as_deref()
7323            .and_then(fmt_git_date),
7324        project_name: project_path
7325            .rsplit(['/', '\\'])
7326            .find(|s| !s.is_empty())
7327            .unwrap_or(&project_path)
7328            .to_string(),
7329        submodule_options,
7330        has_any_submodule_data,
7331        active_submodule,
7332        super_scope_active,
7333        csp_nonce,
7334        coverage_delta_card: build_coverage_delta_card(s),
7335        baseline_test_count: effective_baseline.summary_totals.test_count,
7336        current_test_count: effective_current.summary_totals.test_count,
7337        baseline_coverage_pct: s.baseline_coverage_line_pct,
7338        current_coverage_pct: s.current_coverage_line_pct,
7339    };
7340
7341    Html(
7342        template
7343            .render()
7344            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7345    )
7346    .into_response()
7347}
7348
7349// ── Badge endpoint ────────────────────────────────────────────────────────────
7350// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
7351// pages, Jira descriptions, etc.
7352//
7353// GET /badge/<metric>?label=<override>&color=<hex>
7354// Metrics: code-lines  files  comment-lines  blank-lines
7355
7356fn format_number(n: u64) -> String {
7357    let s = n.to_string();
7358    let mut out = String::with_capacity(s.len() + s.len() / 3);
7359    let len = s.len();
7360    for (i, c) in s.chars().enumerate() {
7361        if i > 0 && (len - i).is_multiple_of(3) {
7362            out.push(',');
7363        }
7364        out.push(c);
7365    }
7366    out
7367}
7368
7369const fn badge_char_width(c: char) -> f64 {
7370    match c {
7371        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7372        'm' | 'w' => 9.0,
7373        ' ' => 4.0,
7374        _ => 6.5,
7375    }
7376}
7377
7378#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7379fn badge_text_px(text: &str) -> u32 {
7380    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7381}
7382
7383fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7384    let lw = badge_text_px(label) + 20;
7385    let rw = badge_text_px(value) + 20;
7386    let total = lw + rw;
7387    let lx = lw / 2;
7388    let rx = lw + rw / 2;
7389    let le = escape_html(label);
7390    let ve = escape_html(value);
7391    let ce = escape_html(color);
7392    format!(
7393        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7394  <rect width="{total}" height="20" fill="#555"/>
7395  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7396  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7397    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7398    <text x="{lx}" y="13">{le}</text>
7399    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7400    <text x="{rx}" y="13">{ve}</text>
7401  </g>
7402</svg>"##
7403    )
7404}
7405
7406#[derive(Deserialize)]
7407struct BadgeQuery {
7408    label: Option<String>,
7409    color: Option<String>,
7410}
7411
7412async fn badge_handler(
7413    State(state): State<AppState>,
7414    AxumPath(metric): AxumPath<String>,
7415    Query(query): Query<BadgeQuery>,
7416) -> Response {
7417    let entry = {
7418        let reg = state.registry.lock().await;
7419        reg.entries.first().cloned()
7420    };
7421
7422    let Some(entry) = entry else {
7423        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7424        return (
7425            [
7426                (header::CONTENT_TYPE, "image/svg+xml"),
7427                (header::CACHE_CONTROL, "no-cache, max-age=0"),
7428            ],
7429            svg,
7430        )
7431            .into_response();
7432    };
7433
7434    let (default_label, value, default_color) = match metric.as_str() {
7435        "code-lines" => (
7436            "code lines",
7437            format_number(entry.summary.code_lines),
7438            "#4a78ee",
7439        ),
7440        "files" => (
7441            "files analyzed",
7442            format_number(entry.summary.files_analyzed),
7443            "#4a9862",
7444        ),
7445        "comment-lines" => (
7446            "comment lines",
7447            format_number(entry.summary.comment_lines),
7448            "#b35428",
7449        ),
7450        "blank-lines" => (
7451            "blank lines",
7452            format_number(entry.summary.blank_lines),
7453            "#7a5db0",
7454        ),
7455        _ => return StatusCode::NOT_FOUND.into_response(),
7456    };
7457
7458    let label = query.label.as_deref().unwrap_or(default_label);
7459    let color = query.color.as_deref().unwrap_or(default_color);
7460    let svg = render_badge_svg(label, &value, color);
7461
7462    (
7463        [
7464            (header::CONTENT_TYPE, "image/svg+xml"),
7465            (header::CACHE_CONTROL, "no-cache, max-age=0"),
7466        ],
7467        svg,
7468    )
7469        .into_response()
7470}
7471
7472// ── Metrics API ───────────────────────────────────────────────────────────────
7473// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
7474// Confluence automation, Jira webhooks, etc.
7475//
7476// GET /api/metrics/latest
7477// GET /api/metrics/<run_id>
7478
7479#[derive(Serialize)]
7480struct ApiCoverageBlock {
7481    lines_found: u64,
7482    lines_hit: u64,
7483    line_pct: f64,
7484    functions_found: u64,
7485    functions_hit: u64,
7486    function_pct: f64,
7487    branches_found: u64,
7488    branches_hit: u64,
7489    branch_pct: f64,
7490}
7491
7492#[derive(Serialize)]
7493struct ApiMetricsResponse {
7494    run_id: String,
7495    timestamp: String,
7496    project: String,
7497    summary: ApiSummaryPayload,
7498    languages: Vec<ApiLanguageRow>,
7499    #[serde(skip_serializing_if = "Option::is_none")]
7500    coverage: Option<ApiCoverageBlock>,
7501}
7502
7503#[derive(Serialize)]
7504struct ApiSummaryPayload {
7505    files_analyzed: u64,
7506    files_skipped: u64,
7507    code_lines: u64,
7508    comment_lines: u64,
7509    blank_lines: u64,
7510    total_physical_lines: u64,
7511    functions: u64,
7512    classes: u64,
7513    variables: u64,
7514    imports: u64,
7515}
7516
7517#[derive(Serialize)]
7518struct ApiLanguageRow {
7519    name: String,
7520    files: u64,
7521    code_lines: u64,
7522    comment_lines: u64,
7523    blank_lines: u64,
7524    functions: u64,
7525    classes: u64,
7526    variables: u64,
7527    imports: u64,
7528}
7529
7530async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7531    let entry = {
7532        let reg = state.registry.lock().await;
7533        reg.entries.first().cloned()
7534    };
7535    entry.map_or_else(
7536        || error::not_found("no scans recorded yet"),
7537        |e| build_metrics_response(&e),
7538    )
7539}
7540
7541async fn api_metrics_run_handler(
7542    State(state): State<AppState>,
7543    AxumPath(run_id): AxumPath<String>,
7544) -> Response {
7545    let entry = {
7546        let reg = state.registry.lock().await;
7547        reg.find_by_run_id(&run_id).cloned()
7548    };
7549    entry.map_or_else(
7550        || error::not_found("run not found"),
7551        |e| build_metrics_response(&e),
7552    )
7553}
7554
7555fn build_metrics_response(entry: &RegistryEntry) -> Response {
7556    let languages: Vec<ApiLanguageRow> = entry
7557        .json_path
7558        .as_ref()
7559        .and_then(|p| read_json(p).ok())
7560        .map(|run| {
7561            run.totals_by_language
7562                .iter()
7563                .map(|l| ApiLanguageRow {
7564                    name: l.language.display_name().to_string(),
7565                    files: l.files,
7566                    code_lines: l.code_lines,
7567                    comment_lines: l.comment_lines,
7568                    blank_lines: l.blank_lines,
7569                    functions: l.functions,
7570                    classes: l.classes,
7571                    variables: l.variables,
7572                    imports: l.imports,
7573                })
7574                .collect()
7575        })
7576        .unwrap_or_default();
7577
7578    let s = &entry.summary;
7579    let coverage = if s.coverage_lines_found > 0 {
7580        let pct = |hit: u64, found: u64| -> f64 {
7581            if found == 0 {
7582                0.0
7583            } else {
7584                #[allow(clippy::cast_precision_loss)]
7585                let v = (hit as f64 / found as f64) * 100.0;
7586                (v * 10.0).round() / 10.0
7587            }
7588        };
7589        Some(ApiCoverageBlock {
7590            lines_found: s.coverage_lines_found,
7591            lines_hit: s.coverage_lines_hit,
7592            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7593            functions_found: s.coverage_functions_found,
7594            functions_hit: s.coverage_functions_hit,
7595            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7596            branches_found: s.coverage_branches_found,
7597            branches_hit: s.coverage_branches_hit,
7598            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7599        })
7600    } else {
7601        None
7602    };
7603    Json(ApiMetricsResponse {
7604        run_id: entry.run_id.clone(),
7605        timestamp: entry.timestamp_utc.to_rfc3339(),
7606        project: entry.project_label.clone(),
7607        summary: ApiSummaryPayload {
7608            files_analyzed: s.files_analyzed,
7609            files_skipped: s.files_skipped,
7610            code_lines: s.code_lines,
7611            comment_lines: s.comment_lines,
7612            blank_lines: s.blank_lines,
7613            total_physical_lines: s.total_physical_lines,
7614            functions: s.functions,
7615            classes: s.classes,
7616            variables: s.variables,
7617            imports: s.imports,
7618        },
7619        languages,
7620        coverage,
7621    })
7622    .into_response()
7623}
7624
7625// ── Project history API ───────────────────────────────────────────────────────
7626// Protected. Called by the wizard JS when the project path changes, so the UI
7627// can show a "scanned N times before" badge without a full page reload.
7628//
7629// GET /api/project-history?path=<project_root>
7630
7631#[derive(Deserialize)]
7632struct ProjectHistoryQuery {
7633    path: Option<String>,
7634}
7635
7636#[derive(Serialize)]
7637struct ProjectHistoryResponse {
7638    scan_count: usize,
7639    last_scan_id: Option<String>,
7640    last_scan_timestamp: Option<String>,
7641    last_scan_code_lines: Option<u64>,
7642    last_git_branch: Option<String>,
7643    last_git_commit: Option<String>,
7644}
7645
7646/// Return true if `entry` matches either an exact root path or an upload-staging
7647/// path with the same project name (needed because each upload gets a fresh UUID dir).
7648fn entry_matches_project(
7649    entry: &RegistryEntry,
7650    root_str: &str,
7651    upload_root: &str,
7652    upload_name_suffix: Option<&str>,
7653) -> bool {
7654    if entry.input_roots.iter().any(|r| r == root_str) {
7655        return true;
7656    }
7657    if let Some(suffix) = upload_name_suffix {
7658        return entry
7659            .input_roots
7660            .iter()
7661            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7662    }
7663    false
7664}
7665
7666async fn project_history_handler(
7667    State(state): State<AppState>,
7668    Query(query): Query<ProjectHistoryQuery>,
7669) -> Response {
7670    let path = query.path.unwrap_or_default();
7671    let resolved = resolve_input_path(&path);
7672    let root_str = resolved.to_string_lossy().replace('\\', "/");
7673
7674    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
7675    // The UUID is freshly generated for every upload, so an exact root_str match never finds
7676    // previous scans of the same project. Fall back to matching by project name within the
7677    // uploads staging directory so Scan History populates correctly across uploads.
7678    let upload_root = std::env::temp_dir()
7679        .join("oxide-sloc-uploads")
7680        .to_string_lossy()
7681        .replace('\\', "/");
7682    let upload_name_suffix: Option<String> =
7683        if state.server_mode && root_str.starts_with(&upload_root) {
7684            resolved
7685                .file_name()
7686                .and_then(|n| n.to_str())
7687                .map(|name| format!("/{name}"))
7688        } else {
7689            None
7690        };
7691    let suffix_ref = upload_name_suffix.as_deref();
7692
7693    let entries: Vec<_> = {
7694        let reg = state.registry.lock().await;
7695        reg.entries
7696            .iter()
7697            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7698            .cloned()
7699            .collect()
7700    };
7701    let scan_count = entries.len();
7702    let last = entries.first();
7703    let last_scan_id = last.map(|e| e.run_id.clone());
7704    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7705    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7706    let last_git_branch = last.and_then(|e| e.git_branch.clone());
7707    let last_git_commit = last.and_then(|e| e.git_commit.clone());
7708
7709    Json(ProjectHistoryResponse {
7710        scan_count,
7711        last_scan_id,
7712        last_scan_timestamp,
7713        last_scan_code_lines,
7714        last_git_branch,
7715        last_git_commit,
7716    })
7717    .into_response()
7718}
7719
7720// ── Metrics history API ───────────────────────────────────────────────────────
7721// Protected. Returns a JSON array of lightweight scan snapshots for plotting
7722// trend charts.
7723//
7724// GET /api/metrics/history?root=<path>&limit=<n>
7725
7726#[derive(Deserialize)]
7727struct MetricsHistoryQuery {
7728    root: Option<String>,
7729    limit: Option<usize>,
7730    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
7731    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
7732    submodule: Option<String>,
7733}
7734
7735#[derive(Serialize)]
7736struct MetricsSubmoduleLink {
7737    name: String,
7738    url: String,
7739}
7740
7741#[derive(Serialize)]
7742struct MetricsHistoryEntry {
7743    run_id: String,
7744    run_id_short: String,
7745    timestamp: String,
7746    commit: Option<String>,
7747    branch: Option<String>,
7748    tags: Vec<String>,
7749    nearest_tag: Option<String>,
7750    code_lines: u64,
7751    comment_lines: u64,
7752    blank_lines: u64,
7753    physical_lines: u64,
7754    files_analyzed: u64,
7755    files_skipped: u64,
7756    test_count: u64,
7757    project_label: String,
7758    html_url: Option<String>,
7759    has_pdf: bool,
7760    submodule_links: Vec<MetricsSubmoduleLink>,
7761    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
7762    #[serde(skip_serializing_if = "Option::is_none")]
7763    coverage_line_pct: Option<f64>,
7764}
7765
7766fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7767    let mut links: Vec<MetricsSubmoduleLink> = vec![];
7768    let sub_dir = e
7769        .html_path
7770        .as_ref()
7771        .and_then(|p| p.parent())
7772        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7773    let Some(dir) = sub_dir else { return links };
7774    let Ok(rd) = std::fs::read_dir(dir) else {
7775        return links;
7776    };
7777    for entry_res in rd.flatten() {
7778        let fname = entry_res.file_name();
7779        let fname_str = fname.to_string_lossy();
7780        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7781            let stem = &fname_str[..fname_str.len() - 5];
7782            let display = stem[4..].replace('-', " ");
7783            links.push(MetricsSubmoduleLink {
7784                name: display,
7785                url: format!("/runs/{stem}/{}", e.run_id),
7786            });
7787        }
7788    }
7789    links.sort_by(|a, b| a.name.cmp(&b.name));
7790    links
7791}
7792
7793fn apply_submodule_filter(
7794    base: MetricsHistoryEntry,
7795    filter: &str,
7796    e: &sloc_core::history::RegistryEntry,
7797) -> Option<MetricsHistoryEntry> {
7798    let json_path = e.json_path.as_ref()?;
7799    let json_str = std::fs::read_to_string(json_path).ok()?;
7800    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7801    let sub = run
7802        .submodule_summaries
7803        .iter()
7804        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7805    let safe = sanitize_project_label(&sub.name);
7806    let artifact_key = format!("sub_{safe}");
7807    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7808        || base.html_url.clone(),
7809        |run_dir| {
7810            let sub_path = run_dir.join(format!("{artifact_key}.html"));
7811            if sub_path.exists() {
7812                Some(format!("/runs/{artifact_key}/{}", e.run_id))
7813            } else {
7814                base.html_url.clone()
7815            }
7816        },
7817    );
7818
7819    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
7820    // basic SLOC totals, so test_count and coverage must be computed from file records.
7821    let sub_files: Vec<_> = run
7822        .per_file_records
7823        .iter()
7824        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7825        .collect();
7826    let test_count: u64 = sub_files
7827        .iter()
7828        .map(|r| r.raw_line_categories.test_count)
7829        .sum();
7830    #[allow(clippy::cast_precision_loss)]
7831    let coverage_line_pct: Option<f64> = {
7832        let found: u64 = sub_files
7833            .iter()
7834            .filter_map(|r| r.coverage.as_ref())
7835            .map(|c| u64::from(c.lines_found))
7836            .sum();
7837        let hit: u64 = sub_files
7838            .iter()
7839            .filter_map(|r| r.coverage.as_ref())
7840            .map(|c| u64::from(c.lines_hit))
7841            .sum();
7842        if found > 0 {
7843            let pct = (hit as f64 / found as f64) * 100.0;
7844            Some((pct * 10.0).round() / 10.0)
7845        } else {
7846            None
7847        }
7848    };
7849
7850    Some(MetricsHistoryEntry {
7851        code_lines: sub.code_lines,
7852        comment_lines: sub.comment_lines,
7853        blank_lines: sub.blank_lines,
7854        physical_lines: sub.total_physical_lines,
7855        files_analyzed: sub.files_analyzed,
7856        files_skipped: 0,
7857        test_count,
7858        html_url: sub_html_url,
7859        has_pdf: false,
7860        submodule_links: vec![],
7861        coverage_line_pct,
7862        ..base
7863    })
7864}
7865
7866#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
7867async fn api_metrics_history_handler(
7868    State(state): State<AppState>,
7869    Query(query): Query<MetricsHistoryQuery>,
7870) -> Response {
7871    let limit = query.limit.unwrap_or(50).min(500);
7872    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7873
7874    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7875        let reg = state.registry.lock().await;
7876        reg.entries
7877            .iter()
7878            .filter(|e| {
7879                query.root.as_ref().is_none_or(|root| {
7880                    let resolved = resolve_input_path(root);
7881                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7882                    e.input_roots.iter().any(|r| r == &root_str)
7883                })
7884            })
7885            .take(limit)
7886            .cloned()
7887            .collect()
7888    };
7889
7890    let entries: Vec<MetricsHistoryEntry> = candidate_entries
7891        .into_iter()
7892        .filter_map(|e| {
7893            let tags = e
7894                .git_tags
7895                .as_deref()
7896                .map(|s| {
7897                    s.split(',')
7898                        .map(|t| t.trim().to_string())
7899                        .filter(|t| !t.is_empty())
7900                        .collect()
7901                })
7902                .unwrap_or_default();
7903            let html_url = e
7904                .html_path
7905                .as_ref()
7906                .filter(|p| p.exists())
7907                .map(|_| format!("/runs/html/{}", e.run_id));
7908            let nearest_tag = e.git_nearest_tag.clone();
7909            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7910            let run_id_short: String = e
7911                .run_id
7912                .split('-')
7913                .next_back()
7914                .unwrap_or(&e.run_id)
7915                .chars()
7916                .take(7)
7917                .collect();
7918            let submodule_links = build_entry_submodule_links(&e);
7919            #[allow(clippy::cast_precision_loss)]
7920            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7921                let pct = (e.summary.coverage_lines_hit as f64
7922                    / e.summary.coverage_lines_found as f64)
7923                    * 100.0;
7924                Some((pct * 10.0).round() / 10.0)
7925            } else {
7926                None
7927            };
7928            let base = MetricsHistoryEntry {
7929                run_id: e.run_id.clone(),
7930                run_id_short,
7931                timestamp: e.timestamp_utc.to_rfc3339(),
7932                commit: e.git_commit.clone(),
7933                branch: e.git_branch.clone(),
7934                tags,
7935                nearest_tag,
7936                code_lines: e.summary.code_lines,
7937                comment_lines: e.summary.comment_lines,
7938                blank_lines: e.summary.blank_lines,
7939                physical_lines: e.summary.total_physical_lines,
7940                files_analyzed: e.summary.files_analyzed,
7941                files_skipped: e.summary.files_skipped,
7942                test_count: e.summary.test_count,
7943                project_label: e.project_label.clone(),
7944                html_url,
7945                has_pdf,
7946                submodule_links,
7947                coverage_line_pct,
7948            };
7949            if let Some(ref filter) = submodule_filter {
7950                apply_submodule_filter(base, filter, &e)
7951            } else {
7952                Some(base)
7953            }
7954        })
7955        .collect();
7956
7957    Json(entries).into_response()
7958}
7959
7960// GET /api/metrics/submodules?root=<path>
7961// Returns the union of distinct submodule names found across all saved scan JSON artifacts
7962// for the given project root (or all roots if omitted).
7963#[derive(Deserialize)]
7964struct MetricsSubmodulesQuery {
7965    root: Option<String>,
7966}
7967
7968#[derive(Serialize)]
7969struct SubmoduleEntry {
7970    name: String,
7971    relative_path: String,
7972}
7973
7974async fn api_metrics_submodules_handler(
7975    State(state): State<AppState>,
7976    Query(query): Query<MetricsSubmodulesQuery>,
7977) -> Response {
7978    let json_paths: Vec<std::path::PathBuf> = {
7979        let reg = state.registry.lock().await;
7980        reg.entries
7981            .iter()
7982            .filter(|e| {
7983                query.root.as_ref().is_none_or(|root| {
7984                    let resolved = resolve_input_path(root);
7985                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7986                    e.input_roots.iter().any(|r| r == &root_str)
7987                })
7988            })
7989            .filter_map(|e| e.json_path.clone())
7990            .collect()
7991    };
7992
7993    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7994    let mut result: Vec<SubmoduleEntry> = Vec::new();
7995
7996    for path in &json_paths {
7997        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7998            continue;
7999        };
8000        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8001            continue;
8002        };
8003        for sub in &run.submodule_summaries {
8004            if seen.insert(sub.name.clone()) {
8005                result.push(SubmoduleEntry {
8006                    name: sub.name.clone(),
8007                    relative_path: sub.relative_path.clone(),
8008                });
8009            }
8010        }
8011    }
8012
8013    result.sort_by(|a, b| a.name.cmp(&b.name));
8014    Json(result).into_response()
8015}
8016
8017// ── CI ingest endpoint ────────────────────────────────────────────────────────
8018// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
8019// server stores and displays results without cloning or scanning anything itself.
8020//
8021// POST /api/ingest?label=<optional_display_name>
8022// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
8023// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
8024
8025#[derive(Deserialize)]
8026struct IngestQuery {
8027    label: Option<String>,
8028}
8029
8030#[derive(Serialize)]
8031struct IngestResponse {
8032    run_id: String,
8033    view_url: String,
8034}
8035
8036async fn api_ingest_handler(
8037    State(state): State<AppState>,
8038    Query(q): Query<IngestQuery>,
8039    Json(run): Json<sloc_core::AnalysisRun>,
8040) -> Response {
8041    let label = q.label.unwrap_or_else(|| {
8042        run.input_roots
8043            .first()
8044            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8045    });
8046
8047    let label_for_task = label.clone();
8048    let result = tokio::task::spawn_blocking(move || {
8049        let html = render_html(&run)?;
8050        let run_id = run.tool.run_id.clone();
8051        let run_id_safe = run_id.len() <= 128
8052            && !run_id.is_empty()
8053            && run_id
8054                .chars()
8055                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8056        if !run_id_safe {
8057            anyhow::bail!(
8058                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8059            );
8060        }
8061        let project_label = sanitize_project_label(&label_for_task);
8062        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8063        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8064            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8065            _ => project_label,
8066        };
8067        let (artifacts, _pending_pdf) = persist_run_artifacts(
8068            &run,
8069            &html,
8070            &output_dir,
8071            &label_for_task,
8072            &file_stem,
8073            RunResultContext::default(),
8074        )?;
8075        Ok::<_, anyhow::Error>((run_id, artifacts, run))
8076    })
8077    .await;
8078
8079    match result {
8080        Ok(Ok((run_id, artifacts, run))) => {
8081            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
8082            (
8083                StatusCode::CREATED,
8084                Json(IngestResponse {
8085                    view_url: format!("/view-reports?run_id={run_id}"),
8086                    run_id,
8087                }),
8088            )
8089                .into_response()
8090        }
8091        Ok(Err(e)) => error::internal(&format!("{e:#}")),
8092        Err(e) => error::internal(&format!("{e}")),
8093    }
8094}
8095
8096// ── Multi-compare page ────────────────────────────────────────────────────────
8097// GET /multi-compare?runs=id1,id2,id3,...
8098
8099fn html_escape(s: &str) -> String {
8100    s.replace('&', "&amp;")
8101        .replace('<', "&lt;")
8102        .replace('>', "&gt;")
8103        .replace('"', "&quot;")
8104}
8105
8106#[allow(clippy::cast_precision_loss)]
8107fn fmt_num(n: i64) -> String {
8108    let a = n.unsigned_abs();
8109    if a >= 1_000_000 {
8110        let v = n as f64 / 1_000_000.0;
8111        let s = format!("{v:.1}");
8112        format!("{}M", s.trim_end_matches(".0"))
8113    } else if a >= 10_000 {
8114        let v = n as f64 / 1_000.0;
8115        let s = format!("{v:.1}");
8116        format!("{}K", s.trim_end_matches(".0"))
8117    } else {
8118        let sign = if n < 0 { "-" } else { "" };
8119        if a < 1_000 {
8120            return format!("{sign}{a}");
8121        }
8122        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
8123    }
8124}
8125
8126fn fmt_comma(n: i64) -> String {
8127    let sign = if n < 0 { "-" } else { "" };
8128    let a = n.unsigned_abs();
8129    if a < 1_000 {
8130        return format!("{sign}{a}");
8131    }
8132    let s = a.to_string();
8133    let bytes = s.as_bytes();
8134    let len = bytes.len();
8135    let mut out = String::with_capacity(len + len / 3);
8136    for (i, &b) in bytes.iter().enumerate() {
8137        if i > 0 && (len - i).is_multiple_of(3) {
8138            out.push(',');
8139        }
8140        out.push(b as char);
8141    }
8142    format!("{sign}{out}")
8143}
8144
8145#[derive(Deserialize, Default)]
8146struct MultiCompareQuery {
8147    runs: Option<String>,
8148    /// "super" to show only super-repo files (exclude all submodule files)
8149    scope: Option<String>,
8150    /// Submodule name to narrow the comparison to one submodule
8151    sub: Option<String>,
8152}
8153
8154#[allow(clippy::too_many_lines)]
8155async fn multi_compare_handler(
8156    State(state): State<AppState>,
8157    Query(params): Query<MultiCompareQuery>,
8158    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8159) -> impl IntoResponse {
8160    let run_ids: Vec<String> = params
8161        .runs
8162        .as_deref()
8163        .unwrap_or("")
8164        .split(',')
8165        .map(|s| s.trim().to_string())
8166        .filter(|s| !s.is_empty())
8167        .collect();
8168
8169    if run_ids.len() < 2 {
8170        return Html(
8171            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
8172             <a href=\"/compare-scans\">Go back</a></p>",
8173        )
8174        .into_response();
8175    }
8176    if run_ids.len() > 20 {
8177        return Html(
8178            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
8179             at once. <a href=\"/compare-scans\">Go back</a></p>",
8180        )
8181        .into_response();
8182    }
8183
8184    // Look up each run_id in the registry.
8185    let entries: Vec<Option<RegistryEntry>> = {
8186        let reg = state.registry.lock().await;
8187        run_ids
8188            .iter()
8189            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
8190            .collect()
8191    };
8192
8193    for (i, entry) in entries.iter().enumerate() {
8194        if entry.is_none() {
8195            let html = format!(
8196                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
8197                 found. <a href=\"/compare-scans\">Go back</a></p>",
8198                run_ids[i]
8199            );
8200            return Html(html).into_response();
8201        }
8202    }
8203
8204    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
8205
8206    for entry in &entries {
8207        if entry.json_path.is_none() {
8208            let html = format!(
8209                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
8210                 JSON data — re-run the analysis to enable comparison. \
8211                 <a href=\"/compare-scans\">Go back</a></p>",
8212                &entry.run_id
8213            );
8214            return Html(html).into_response();
8215        }
8216    }
8217
8218    // Sort chronologically.
8219    entries.sort_by_key(|e| e.timestamp_utc);
8220
8221    // Load JSON for each entry.
8222    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
8223    for entry in &entries {
8224        let path = entry.json_path.as_ref().unwrap();
8225        match read_json(path) {
8226            Ok(r) => runs.push(r),
8227            Err(e) => {
8228                let html = format!(
8229                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
8230                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
8231                    &entry.run_id
8232                );
8233                return Html(html).into_response();
8234            }
8235        }
8236    }
8237
8238    // Collect submodule names from all runs.
8239    let all_sub_names: Vec<String> = {
8240        let mut set = std::collections::BTreeSet::new();
8241        for r in &runs {
8242            for s in &r.submodule_summaries {
8243                set.insert(s.name.clone());
8244            }
8245        }
8246        set.into_iter().collect()
8247    };
8248    let has_submodule_data = !all_sub_names.is_empty();
8249    let active_submodule = params.sub.clone();
8250    let super_scope_active = params.scope.as_deref() == Some("super");
8251
8252    // Narrow per_file_records when a scope is active, then recompute totals.
8253    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
8254
8255    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
8256    let project_label = entries
8257        .first()
8258        .map_or("", |e| e.project_label.as_str())
8259        .to_string();
8260    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
8261    let multi = compute_multi_delta(&run_refs);
8262    let html = multi_compare_page(
8263        &multi,
8264        &project_label,
8265        env!("CARGO_PKG_VERSION"),
8266        &csp_nonce,
8267        has_submodule_data,
8268        &all_sub_names,
8269        &runs_csv,
8270        super_scope_active,
8271        active_submodule.as_deref(),
8272        &entries,
8273    );
8274    // no-store: this page is regenerated on every request and embeds inline JS; a cached
8275    // copy after a rebuild would silently mask UI fixes.
8276    (
8277        [(axum::http::header::CACHE_CONTROL, "no-store")],
8278        Html(html),
8279    )
8280        .into_response()
8281}
8282
8283const fn multi_delta_class(n: i64) -> &'static str {
8284    match n {
8285        1.. => "pos",
8286        ..=-1 => "neg",
8287        0 => "zero",
8288    }
8289}
8290
8291fn multi_fmt_delta(n: i64) -> String {
8292    if n > 0 {
8293        format!("+{n}")
8294    } else {
8295        format!("{n}")
8296    }
8297}
8298
8299/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
8300fn js_escape(s: &str) -> String {
8301    use std::fmt::Write as _;
8302    let mut out = String::with_capacity(s.len() + 2);
8303    for c in s.chars() {
8304        match c {
8305            '"' => out.push_str("\\\""),
8306            '\\' => out.push_str("\\\\"),
8307            '\n' => out.push_str("\\n"),
8308            '\r' => out.push_str("\\r"),
8309            '\t' => out.push_str("\\t"),
8310            c if (c as u32) < 0x20 => {
8311                let _ = write!(out, "\\u{:04x}", c as u32);
8312            }
8313            c => out.push(c),
8314        }
8315    }
8316    out
8317}
8318
8319/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
8320fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
8321    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
8322        return (
8323            "&mdash;".to_string(),
8324            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
8325        );
8326    };
8327    let cd = entry
8328        .git_commit_date
8329        .as_deref()
8330        .and_then(fmt_git_date)
8331        .unwrap_or_else(|| "&mdash;".to_string());
8332    let au = entry.git_author.as_deref().map_or_else(
8333        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
8334        |a| {
8335            format!(
8336                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
8337                 <span class=\"cmp-author-handle\"></span></span>",
8338                html_escape(a)
8339            )
8340        },
8341    );
8342    (cd, au)
8343}
8344
8345/// Render the scope badge chip for a scan card header.
8346fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
8347    active_sub.map_or_else(
8348        || {
8349            if super_scope_active {
8350                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
8351            } else {
8352                "<span class=\"mc-scope-tag mc-scope-full\">\
8353                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
8354                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
8355                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
8356                 <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>\
8357                 </svg> Full scan</span>"
8358                    .to_string()
8359            }
8360        },
8361        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
8362    )
8363}
8364
8365/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
8366fn build_mc_scan_strip(
8367    multi: &MultiScanComparison,
8368    entries: &[RegistryEntry],
8369    n: usize,
8370    is_many: bool,
8371    active_sub: Option<&str>,
8372    super_scope_active: bool,
8373    project_label: &str,
8374) -> String {
8375    use std::fmt::Write as _;
8376    let mut scan_strip = String::new();
8377    for (i, pt) in multi.points.iter().enumerate() {
8378        let ts_ms = pt.timestamp.timestamp_millis();
8379        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8380        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
8381        let branch = pt.git_branch.as_deref().unwrap_or("");
8382        let report_link = format!("/runs/html/{}", pt.run_id);
8383        let branch_html = if branch.is_empty() {
8384            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
8385        } else {
8386            format!(
8387                "<span class=\"mc-card-branch\">{}</span>",
8388                html_escape(branch)
8389            )
8390        };
8391        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
8392        let tags_html = pt
8393            .git_tags
8394            .as_deref()
8395            .filter(|t| !t.is_empty())
8396            .map(|t| {
8397                let chips = t
8398                    .split(',')
8399                    .filter(|s| !s.is_empty())
8400                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
8401                    .collect::<Vec<_>>()
8402                    .join(" ");
8403                format!(
8404                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
8405                     <span class=\"mc-row-val\">{chips}</span></div>"
8406                )
8407            })
8408            .unwrap_or_default();
8409        let nearest = pt
8410            .git_nearest_tag
8411            .as_deref()
8412            .map(|t| format!("near {}", html_escape(t)))
8413            .unwrap_or_default();
8414        let arrow = if i < n - 1 && !is_many {
8415            "<div class='mc-arrow'>&#8594;</div>"
8416        } else {
8417            ""
8418        };
8419        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
8420        let nearest_html = if nearest.is_empty() {
8421            String::new()
8422        } else {
8423            format!(
8424                "<span class=\"mc-card-nearest-wrap\">\
8425                 <span class=\"mc-card-nearest\">{nearest}</span>\
8426                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
8427                 </span>"
8428            )
8429        };
8430        write!(
8431            scan_strip,
8432            r#"<div class="mc-card">
8433              <div class="mc-card-header">
8434                <div class="mc-card-num">Scan {num}</div>
8435                <div class="mc-card-project-col">
8436                  <div class="mc-card-project">{project_label}</div>
8437                  {scope_badge}
8438                </div>
8439              </div>
8440              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
8441              <div class="mc-card-rows">
8442                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
8443                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
8444                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
8445                <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>
8446                {tags_html}
8447              </div>
8448              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
8449            </div>{arrow}"#,
8450            num = i + 1,
8451            commit = html_escape(commit),
8452            commit_date = commit_date_html,
8453            ts_ms = ts_ms,
8454            code = fmt_num(pt.code_lines),
8455            scope_badge = scope_badge,
8456            nearest_html = nearest_html,
8457        )
8458        .unwrap();
8459    }
8460    scan_strip
8461}
8462
8463/// Build the metric progression table (thead + tbody) for multi-compare.
8464#[allow(clippy::too_many_lines)]
8465fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
8466    use std::fmt::Write as _;
8467    struct MetricRow<'a> {
8468        label: &'a str,
8469        values: Vec<i64>,
8470        seq_deltas: Vec<i64>,
8471        net_delta: i64,
8472    }
8473    let rows: Vec<MetricRow<'_>> = vec![
8474        MetricRow {
8475            label: "Code Lines",
8476            values: multi.points.iter().map(|p| p.code_lines).collect(),
8477            seq_deltas: multi
8478                .sequential_deltas
8479                .iter()
8480                .map(|d| d.summary.code_lines_delta)
8481                .collect(),
8482            net_delta: multi.total_delta.code_lines_delta,
8483        },
8484        MetricRow {
8485            label: "Files Analyzed",
8486            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
8487            seq_deltas: multi
8488                .sequential_deltas
8489                .iter()
8490                .map(|d| d.summary.files_analyzed_delta)
8491                .collect(),
8492            net_delta: multi.total_delta.files_analyzed_delta,
8493        },
8494        MetricRow {
8495            label: "Comment Lines",
8496            values: multi.points.iter().map(|p| p.comment_lines).collect(),
8497            seq_deltas: multi
8498                .sequential_deltas
8499                .iter()
8500                .map(|d| d.summary.comment_lines_delta)
8501                .collect(),
8502            net_delta: multi.total_delta.comment_lines_delta,
8503        },
8504        MetricRow {
8505            label: "Blank Lines",
8506            values: multi.points.iter().map(|p| p.blank_lines).collect(),
8507            seq_deltas: multi
8508                .sequential_deltas
8509                .iter()
8510                .map(|d| d.summary.blank_lines_delta)
8511                .collect(),
8512            net_delta: multi.total_delta.blank_lines_delta,
8513        },
8514        MetricRow {
8515            label: "Tests",
8516            values: multi.points.iter().map(|p| p.test_count).collect(),
8517            seq_deltas: multi
8518                .points
8519                .windows(2)
8520                .map(|pts| pts[1].test_count - pts[0].test_count)
8521                .collect(),
8522            net_delta: multi.points.last().map_or(0, |l| l.test_count)
8523                - multi.points.first().map_or(0, |f| f.test_count),
8524        },
8525    ];
8526    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
8527    for i in 0..n {
8528        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
8529        if i < n - 1 {
8530            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
8531        }
8532    }
8533    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
8534    let mut metrics_tbody = String::new();
8535    for row in &rows {
8536        metrics_tbody.push_str("<tr>");
8537        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
8538        for i in 0..n {
8539            write!(
8540                metrics_tbody,
8541                "<td class='mc-val-col'>{}</td>",
8542                fmt_comma(row.values[i])
8543            )
8544            .unwrap();
8545            if i < n - 1 {
8546                let d = row.seq_deltas[i];
8547                write!(
8548                    metrics_tbody,
8549                    "<td class='mc-delta-col {cls}'>{val}</td>",
8550                    cls = multi_delta_class(d),
8551                    val = multi_fmt_delta(d)
8552                )
8553                .unwrap();
8554            }
8555        }
8556        let nd = row.net_delta;
8557        write!(
8558            metrics_tbody,
8559            "<td class='mc-net-col {cls}'>{val}</td>",
8560            cls = multi_delta_class(nd),
8561            val = multi_fmt_delta(nd)
8562        )
8563        .unwrap();
8564        metrics_tbody.push_str("</tr>");
8565    }
8566    (metrics_thead, metrics_tbody)
8567}
8568
8569/// Build the JS-embeddable points JSON array for the multi-compare chart.
8570fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
8571    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
8572    for (i, pt) in multi.points.iter().enumerate() {
8573        let commit = pt.git_commit.as_deref().unwrap_or("");
8574        let branch = pt.git_branch.as_deref().unwrap_or("");
8575        let tags = pt.git_tags.as_deref().unwrap_or("");
8576        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
8577        let scanned_ms = pt.timestamp.timestamp_millis();
8578        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8579        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
8580        let commit_date = entry
8581            .and_then(|e| e.git_commit_date.as_deref())
8582            .and_then(fmt_git_date)
8583            .unwrap_or_default();
8584        let author = entry
8585            .and_then(|e| e.git_author.as_deref())
8586            .unwrap_or("")
8587            .to_string();
8588        let cov = pt
8589            .coverage_line_pct
8590            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
8591        parts.push(format!(
8592            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}}}"#,
8593            run_id = js_escape(&pt.run_id),
8594            commit = js_escape(commit),
8595            branch = js_escape(branch),
8596            tags = js_escape(tags),
8597            nearest = js_escape(nearest),
8598            commit_date = js_escape(&commit_date),
8599            author = js_escape(&author),
8600            scanned = js_escape(&scanned),
8601            code = pt.code_lines,
8602            comments = pt.comment_lines,
8603            blank = pt.blank_lines,
8604            files = pt.files_analyzed,
8605            tests = pt.test_count,
8606        ));
8607    }
8608    format!("[{}]", parts.join(","))
8609}
8610
8611/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
8612fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
8613    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
8614    for row in &multi.file_matrix {
8615        let lang = row.language.as_deref().unwrap_or("");
8616        let codes: Vec<String> = row
8617            .code_per_scan
8618            .iter()
8619            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8620            .collect();
8621        let deltas: Vec<String> = row
8622            .code_delta_per_scan
8623            .iter()
8624            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8625            .collect();
8626        parts.push(format!(
8627            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
8628            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
8629            status = row.overall_status,
8630            codes = codes.join(","),
8631            deltas = deltas.join(","),
8632            total = row.total_code_delta,
8633        ));
8634    }
8635    format!("[{}]", parts.join(","))
8636}
8637
8638/// Build the column header cells for the file-matrix table.
8639fn build_mc_file_col_headers(n: usize) -> String {
8640    use std::fmt::Write as _;
8641    let mut out = String::new();
8642    for i in 0..n {
8643        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
8644        if i < n - 1 {
8645            write!(
8646                out,
8647                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
8648                i + 2
8649            )
8650            .unwrap();
8651        }
8652    }
8653    out
8654}
8655
8656/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
8657fn build_mc_scope_bar(
8658    has_submodule_data: bool,
8659    sub_names: &[String],
8660    runs_csv: &str,
8661    active_sub: Option<&str>,
8662    super_scope_active: bool,
8663) -> String {
8664    use std::fmt::Write as _;
8665    if !has_submodule_data {
8666        return String::new();
8667    }
8668    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
8669    let full_active = active_sub.is_none() && !super_scope_active;
8670    let mut bar = format!(
8671        r#"<div class="submod-scope-bar">
8672  <span class="submod-scope-label">
8673    <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>
8674    Scope:
8675  </span>
8676  <div class="submod-scope-divider"></div>
8677  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
8678  <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>"#,
8679        full_cls = if full_active { " active" } else { "" },
8680        super_cls = if super_scope_active { " active" } else { "" },
8681    );
8682    for s in sub_names {
8683        let is_active = active_sub == Some(s.as_str());
8684        write!(
8685            bar,
8686            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
8687            cls = if is_active { " active" } else { "" },
8688            name_enc = html_escape(s),
8689            name_esc = html_escape(s),
8690        )
8691        .unwrap();
8692    }
8693    bar.push_str("\n</div>");
8694    bar
8695}
8696
8697/// Build the scope-description label shown in the page subtitle.
8698fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
8699    active_sub.map_or_else(
8700        || {
8701            if super_scope_active {
8702                "Super-repo only &mdash; ".to_string()
8703            } else {
8704                String::new()
8705            }
8706        },
8707        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
8708    )
8709}
8710
8711#[allow(clippy::too_many_lines)]
8712#[allow(clippy::too_many_arguments)]
8713fn multi_compare_page(
8714    multi: &MultiScanComparison,
8715    project_label: &str,
8716    version: &str,
8717    csp_nonce: &str,
8718    has_submodule_data: bool,
8719    sub_names: &[String],
8720    runs_csv: &str,
8721    super_scope_active: bool,
8722    active_sub: Option<&str>,
8723    entries: &[RegistryEntry],
8724) -> String {
8725    let n = multi.points.len();
8726    let is_many = n > 4;
8727    let mc_strip_class = if is_many {
8728        "mc-strip mc-strip-grid"
8729    } else {
8730        "mc-strip"
8731    };
8732
8733    // ── Scan strip cards ──────────────────────────────────────────────────────
8734    let scan_strip = build_mc_scan_strip(
8735        multi,
8736        entries,
8737        n,
8738        is_many,
8739        active_sub,
8740        super_scope_active,
8741        project_label,
8742    );
8743
8744    // ── Summary metrics table ─────────────────────────────────────────────────
8745    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
8746
8747    // ── Chart data and table helpers ──────────────────────────────────────────
8748    let points_json = build_mc_points_json(multi, entries);
8749    let file_matrix_json = build_mc_file_matrix_json(multi);
8750
8751    // Counts for filter tabs
8752    let files_modified = multi
8753        .file_matrix
8754        .iter()
8755        .filter(|f| f.overall_status == "modified")
8756        .count();
8757    let files_added = multi
8758        .file_matrix
8759        .iter()
8760        .filter(|f| f.overall_status == "added")
8761        .count();
8762    let files_removed = multi
8763        .file_matrix
8764        .iter()
8765        .filter(|f| f.overall_status == "removed")
8766        .count();
8767    let files_unchanged = multi
8768        .file_matrix
8769        .iter()
8770        .filter(|f| f.overall_status == "unchanged")
8771        .count();
8772    let total_files = multi.file_matrix.len();
8773
8774    let file_col_headers = build_mc_file_col_headers(n);
8775    let nav_compare_active = "";
8776    let scope_bar_html = build_mc_scope_bar(
8777        has_submodule_data,
8778        sub_names,
8779        runs_csv,
8780        active_sub,
8781        super_scope_active,
8782    );
8783    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
8784
8785    format!(
8786        r##"<!doctype html>
8787<html lang="en">
8788<head>
8789  <meta charset="utf-8">
8790  <meta name="viewport" content="width=device-width, initial-scale=1">
8791  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
8792  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8793  <style nonce="{csp_nonce}">
8794    :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;}}
8795    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
8796    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
8797    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;}}
8798    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8799    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
8800    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8801    .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;}}
8802    @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));}}}}
8803    .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);}}
8804    .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;}}
8805    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
8806    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
8807    @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;}}}}
8808    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
8809    .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));}}
8810    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8811    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
8812    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
8813    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
8814    .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;}}
8815    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8816    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
8817    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8818    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8819    .nav-dropdown{{position:relative;display:inline-flex;}}
8820    .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;}}
8821    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8822    .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;}}
8823    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
8824    .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);}}
8825    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
8826    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
8827    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
8828    body:not(.dark-theme) .icon-sun{{display:none;}}
8829    body.dark-theme .icon-moon{{display:none;}}
8830    .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;}}
8831    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8832    .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);}}
8833    .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;}}
8834    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
8835    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
8836    .settings-modal-body{{padding:14px 16px 16px;}}
8837    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
8838    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8839    .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;}}
8840    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
8841    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
8842    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
8843    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
8844    .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;}}
8845    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8846    .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;}}
8847    .btn-back:hover{{background:var(--line);}}
8848    .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;}}
8849    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;}}
8850    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
8851    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
8852    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
8853    .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;}}
8854    .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;}}
8855    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
8856    .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;}}
8857    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
8858    body.dark-theme .mc-card{{background:var(--surface-2);}}
8859    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
8860    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
8861    .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%;}}
8862    .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;}}
8863    .mc-card-commit:hover{{color:var(--oxide);}}
8864    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
8865    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
8866    .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;}}
8867    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
8868    .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;}}
8869    .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;}}
8870    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
8871    .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;}}
8872    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
8873    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
8874    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
8875    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
8876    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
8877    .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);}}
8878    .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);}}
8879    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
8880    .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;}}
8881    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
8882    .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;}}
8883    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
8884    .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;}}
8885    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
8886    .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;}}
8887    .submod-scope-btn:hover{{background:var(--line);}}
8888    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8889    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
8890    .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;}}
8891    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
8892    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8893    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
8894    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
8895    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
8896    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
8897    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
8898    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
8899    .metrics-table .pos{{color:var(--pos);}}
8900    .metrics-table .neg{{color:var(--neg);}}
8901    .metrics-table .zero{{color:var(--muted);}}
8902    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
8903    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8904    .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;}}
8905    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8906    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
8907    .chart-wrap{{width:100%;overflow-x:auto;}}
8908    #mc-chart{{display:block;width:100%;}}
8909    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
8910    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
8911    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
8912    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
8913    .ic-card{{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}}
8914    body.dark-theme .ic-card{{border-color:var(--line-strong);}}
8915    .ic-card-h2{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}}
8916    .ic-card-h2-row{{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}}
8917    .ic-card-h2-row .ic-card-h2{{margin:0;}}
8918    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
8919    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
8920    .ic-cb{{cursor:pointer;transition:filter .15s;}}
8921    .ic-cb:hover{{filter:brightness(1.12);}}
8922    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
8923    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
8924    #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;}}
8925    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8926    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
8927    .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;}}
8928    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8929    .tab-btn:hover:not(.active){{background:var(--line);}}
8930    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
8931    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
8932    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
8933    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
8934    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
8935    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
8936    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
8937    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
8938    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
8939    .table-wrap{{width:100%;overflow-x:auto;}}
8940    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
8941    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
8942    #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;}}
8943    #file-table th.left,#file-table td.left{{text-align:left;}}
8944    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
8945    .file-delta-col{{color:var(--muted);font-size:11px;}}
8946    .file-net-col{{font-weight:800;}}
8947    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
8948    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
8949    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
8950    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
8951    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
8952    .status-badge.modified{{background:#fff2d8;color:#926000;}}
8953    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
8954    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
8955    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
8956    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
8957    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
8958    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
8959    tr.row-added td{{background:rgba(26,143,71,0.04);}}
8960    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
8961    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
8962    tr.row-unchanged td{{color:var(--muted);}}
8963    tr.row-unchanged .status-badge{{opacity:.65;}}
8964    .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;}}
8965    .absent{{color:var(--muted);font-style:italic;}}
8966    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
8967    .pagination-info{{font-size:12px;color:var(--muted);}}
8968    .pagination-btns{{display:flex;gap:5px;}}
8969    .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;}}
8970    .pg-btn:hover:not(:disabled){{background:var(--line);}}
8971    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8972    .pg-btn:disabled{{opacity:.35;cursor:default;}}
8973    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;}}
8974    .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;}}
8975    .export-btn:hover{{background:var(--line);}}
8976    .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;}}
8977    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8978    .site-footer a{{color:var(--muted);}}
8979    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;}}
8980    body.pdf-mode{{background:#fff!important;}}
8981    .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;}}
8982    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
8983    .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;}}
8984    .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;}}
8985    .mc-modal-title{{font-size:18px;font-weight:800;}}
8986    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
8987    .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;}}
8988    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
8989    .mc-modal-body{{padding:18px 22px;}}
8990    .mc-modal-sec{{margin-bottom:20px;}}
8991    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
8992    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
8993    .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;}}
8994    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
8995    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
8996    .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;}}
8997    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
8998    .mc-modal-row:last-child{{border-bottom:none;}}
8999    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9000    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9001    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9002    .mc-modal-val a:hover{{text-decoration:underline;}}
9003    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9004    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9005    .mc-modal-stat[data-tip]{{cursor:help;}}
9006    #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);}}
9007    .mc-card{{cursor:pointer;}}
9008    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
9009  </style>
9010</head>
9011<body>
9012  <div class="background-watermarks" aria-hidden="true">
9013    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9014    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9015    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9016    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9017    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9018    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9019  </div>
9020  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9021  <div class="top-nav">
9022    <div class="top-nav-inner">
9023      <a class="brand" href="/">
9024        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9025        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
9026      </a>
9027      <div class="nav-right">
9028        <a class="nav-pill" href="/">Home</a>
9029        <div class="nav-dropdown">
9030          <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>
9031          <div class="nav-dropdown-menu">
9032            <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>
9033          </div>
9034        </div>
9035        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
9036        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9037        <div class="nav-dropdown">
9038          <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>
9039          <div class="nav-dropdown-menu">
9040            <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>
9041          </div>
9042        </div>
9043        <div class="server-status-wrap" id="server-status-wrap">
9044          <div class="nav-pill server-online-pill" id="server-status-pill">
9045            <span class="status-dot" id="status-dot"></span>
9046            <span id="server-status-label">Server</span>
9047            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9048          </div>
9049          <div class="server-status-tip">
9050            OxideSLOC is running &mdash; accessible on your network.
9051            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9052          </div>
9053        </div>
9054        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9055          <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>
9056        </button>
9057        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9058          <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>
9059          <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>
9060        </button>
9061      </div>
9062    </div>
9063  </div>
9064
9065  <div class="page">
9066    <!-- Hero header -->
9067    <div class="mc-hero">
9068      <div class="mc-hero-header">
9069        <div>
9070          <div class="mc-title">Multi-Scan Timeline</div>
9071          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
9072          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
9073        </div>
9074        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9075          <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>
9076          <div class="export-group" id="mc-top-export-group">
9077            <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>
9078            <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>
9079          </div>
9080        </div>
9081      </div>
9082      {scope_bar_html}
9083      <!-- Scan strip -->
9084      <div class="{mc_strip_class}">{scan_strip}</div>
9085    </div>
9086
9087    <!-- Summary metrics table -->
9088    <div class="panel">
9089      <div class="panel-title">Metric Progression</div>
9090      <div class="table-wrap">
9091        <table class="metrics-table">
9092          <thead>{metrics_thead}</thead>
9093          <tbody>{metrics_tbody}</tbody>
9094        </table>
9095      </div>
9096    </div>
9097
9098    <!-- Scan Charts -->
9099    <div class="panel" id="mc-charts-panel">
9100      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
9101      <div class="ic-grid">
9102        <!-- Timeline line chart — spans full width -->
9103        <div class="ic-card" style="grid-column:span 2">
9104          <div class="ic-card-h2-row">
9105            <span class="ic-card-h2">Timeline</span>
9106            <div class="chart-toolbar" style="margin:0">
9107              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
9108              <button class="chart-metric-btn" data-metric="files">Files</button>
9109              <button class="chart-metric-btn" data-metric="comments">Comments</button>
9110              <button class="chart-metric-btn" data-metric="tests">Tests</button>
9111              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
9112            </div>
9113          </div>
9114          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
9115        </div>
9116        <!-- Code Metrics: Scan 1 vs Latest -->
9117        <div class="ic-card">
9118          <div class="ic-card-h2">Code Metrics &mdash; Scan 1 vs Latest</div>
9119          <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>
9120          <div id="mc-ic-c1"></div>
9121        </div>
9122        <!-- Language Code Delta -->
9123        <div class="ic-card" id="mc-ic-lang-card">
9124          <div class="ic-card-h2">Language Code Delta</div>
9125          <div id="mc-ic-c3"></div>
9126        </div>
9127        <!-- Delta by Metric -->
9128        <div class="ic-card">
9129          <div class="ic-card-h2">Delta by Metric</div>
9130          <div id="mc-ic-c2"></div>
9131        </div>
9132        <!-- File Change Distribution -->
9133        <div class="ic-card">
9134          <div class="ic-card-h2">File Change Distribution</div>
9135          <div id="mc-ic-c4"></div>
9136        </div>
9137      </div>
9138    </div>
9139
9140    <!-- File matrix table -->
9141    <div class="panel">
9142      <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>
9143      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
9144        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
9145          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
9146          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
9147          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
9148          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
9149          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
9150        </div>
9151        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9152          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
9153          <div class="export-group">
9154          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
9155          <button type="button" class="export-btn" id="export-csv-btn">
9156            <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>
9157            CSV
9158          </button>
9159          <button type="button" class="export-btn" id="mc-file-xls-btn">
9160            <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>
9161            Excel
9162          </button>
9163          </div>
9164        </div>
9165      </div>
9166      <div class="table-wrap">
9167        <table id="file-table">
9168          <thead>
9169            <tr>
9170              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
9171              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
9172              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
9173              {file_col_headers}
9174              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
9175            </tr>
9176          </thead>
9177          <tbody id="file-tbody"></tbody>
9178        </table>
9179      </div>
9180      <div class="pagination">
9181        <span class="pagination-info" id="pg-info"></span>
9182        <div class="pagination-btns" id="pg-btns"></div>
9183        <div style="display:flex;align-items:center;gap:6px;">
9184          <span style="font-size:12px;color:var(--muted)">Show</span>
9185          <select class="per-page" id="per-page-sel">
9186            <option value="25" selected>25 per page</option>
9187            <option value="50">50 per page</option>
9188            <option value="100">100 per page</option>
9189          </select>
9190        </div>
9191      </div>
9192    </div>
9193  </div>
9194
9195  <div id="mc-ic-tt"></div>
9196
9197  <footer class="site-footer">
9198    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
9199    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9200    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9201    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9202    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9203  </footer>
9204
9205  <script nonce="{csp_nonce}">
9206  (function(){{
9207    // ── Dark theme ───────────────────────────────────────────────────────────
9208    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
9209    var tt=document.getElementById('theme-toggle');
9210    if(tt)tt.addEventListener('click',function(){{
9211      var on=document.body.classList.toggle('dark-theme');
9212      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
9213      renderChart(activeMetric);
9214    }});
9215
9216    // ── Code particles ───────────────────────────────────────────────────────
9217    var container=document.getElementById('code-particles');
9218    if(container){{
9219      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()'];
9220      for(var i=0;i<28;i++){{
9221        (function(idx){{
9222          var el=document.createElement('span');el.className='code-particle';
9223          el.textContent=snips[idx%snips.length];
9224          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
9225          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
9226          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
9227          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
9228          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
9229          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
9230          container.appendChild(el);
9231        }})(i);
9232      }}
9233    }}
9234
9235    // ── Watermarks ───────────────────────────────────────────────────────────
9236    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9237    if(wms.length){{
9238      var placed=[];
9239      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;}}
9240      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];}}
9241      var half=Math.floor(wms.length/2);
9242      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;}});
9243    }}
9244
9245    // ── Settings / colour scheme modal ───────────────────────────────────────
9246    (function(){{
9247      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'}}];
9248      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);}});}}
9249      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
9250      function init(){{
9251        var btn=document.getElementById('settings-btn');if(!btn)return;
9252        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9253        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>';
9254        document.body.appendChild(m);
9255        var g=document.getElementById('scheme-grid');
9256        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);}});
9257        var cl=document.getElementById('settings-close-btn');
9258        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');}});
9259        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9260        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9261      }}
9262      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9263    }})();
9264
9265    // ── Timezone support for scan timestamps ─────────────────────────────────
9266    (function(){{
9267      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';}};
9268      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'';}}}};
9269      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);}});}};
9270      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
9271      window.applyTz(storedTz);
9272      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
9273      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
9274    }})();
9275
9276    // ── Data ────────────────────────────────────────────────────────────────
9277    var POINTS={points_json};
9278    var FILES={file_matrix_json};
9279    var N={n};
9280
9281    // ── fmt helper ───────────────────────────────────────────────────────────
9282    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();}}
9283    function fmtFull(n){{return Number(n).toLocaleString();}}
9284    function fmtDelta(n){{return n>0?'+'+fmt(n):fmt(n);}}
9285
9286    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
9287    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
9288    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));}}
9289    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
9290    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
9291
9292    // ── Timeline chart ───────────────────────────────────────────────────────
9293    var activeMetric='code';
9294    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
9295    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
9296
9297    function renderChart(metric){{
9298      var svg=document.getElementById('mc-chart');if(!svg)return;
9299      var W=svg.getBoundingClientRect().width||800,H=280;
9300      svg.setAttribute('height',H);
9301      var pad={{l:62,r:20,t:32,b:72}};
9302      var dark=document.body.classList.contains('dark-theme');
9303      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
9304      var valid=pts.filter(function(v){{return v!=null;}});
9305      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;}}
9306      var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
9307      if(minV===maxV){{minV=Math.max(0,minV-1);maxV=maxV+1;}}
9308      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
9309      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
9310      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
9311      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
9312      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
9313      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
9314      var parts=[];
9315      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
9316      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>');}}
9317      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
9318      var lineD='';var firstPt=true;
9319      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);}}}}
9320      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
9321      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
9322      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
9323      for(var i=0;i<N;i++){{
9324        if(pts[i]==null)continue;
9325        var cx=xOf(i),cy=yOf(pts[i]);
9326        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
9327        var hasTag=p.tags&&p.tags.length>0;
9328        // Permanent Y-value label above the dot
9329        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>');
9330        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+'\'"/>');
9331        var xanchor=i===0?'start':i===N-1?'end':'middle';
9332        // X-axis label at 2× the original size (18 px)
9333        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>');
9334      }}
9335      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
9336      svg.setAttribute('viewBox','0 0 '+W+' '+H);
9337      svg.innerHTML=parts.join('');
9338      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
9339      svg.onmousemove=function(e){{
9340        var rect=svg.getBoundingClientRect();
9341        var scaleX=W/rect.width;
9342        var mouseX=(e.clientX-rect.left)*scaleX;
9343        var nearest=-1,minDist=Infinity;
9344        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;}}}}
9345        if(nearest<0)return;
9346        var nc=xOf(nearest),ny=yOf(pts[nearest]);
9347        var xhair=svg.querySelector('.mc-xhair');
9348        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
9349        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"/>';
9350        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
9351        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
9352        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>';
9353        var bx=rect.left+(nc/W*rect.width)+18;
9354        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
9355        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
9356      }};
9357      svg.onmouseleave=function(){{
9358        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
9359        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
9360      }};
9361    }}
9362
9363    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9364
9365    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
9366      btn.addEventListener('click',function(){{
9367        activeMetric=this.dataset.metric;
9368        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
9369        this.classList.add('active');
9370        renderChart(activeMetric);
9371      }});
9372    }});
9373    if(typeof ResizeObserver!=='undefined'){{
9374      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
9375    }}
9376    renderChart(activeMetric);
9377
9378    // ── File matrix table ────────────────────────────────────────────────────
9379    var activeStatus='';
9380    var currentPage=1;
9381    var perPage=25;
9382    var mcSortCol=null,mcSortAsc=true;
9383
9384    function getFiltered(){{
9385      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
9386      if(!mcSortCol)return data;
9387      var asc=mcSortAsc;
9388      return data.slice().sort(function(a,b){{
9389        var va,vb;
9390        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
9391        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
9392        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
9393        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
9394        else{{return 0;}}
9395        if(asc)return va<vb?-1:va>vb?1:0;
9396        return va<vb?1:va>vb?-1:0;
9397      }});
9398    }}
9399
9400    function renderFilePage(){{
9401      var filtered=getFiltered();
9402      var total=filtered.length;
9403      var totalPages=Math.max(1,Math.ceil(total/perPage));
9404      if(currentPage>totalPages)currentPage=totalPages;
9405      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
9406      var tbody=document.getElementById('file-tbody');if(!tbody)return;
9407      var rows=[];
9408      for(var i=start;i<end;i++){{
9409        var f=filtered[i];
9410        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
9411        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
9412        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
9413        for(var j=0;j<N;j++){{
9414          var cv=f.c[j];
9415          cells+='<td class="file-scan-col">'+(cv!=null?fmt(cv):'<span class="absent">\u2014</span>')+'</td>';
9416          if(j<N-1){{
9417            var dv=f.d[j+1];
9418            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
9419              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
9420          }}
9421        }}
9422        var tc=f.t;
9423        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
9424        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
9425      }}
9426      tbody.innerHTML=rows.join('');
9427
9428      var info=document.getElementById('pg-info');
9429      if(info)info.textContent='Showing '+(total?start+1:0)+'–'+end+' of '+total+' files';
9430      renderPgBtns(totalPages);
9431    }}
9432
9433    function renderPgBtns(totalPages){{
9434      var wrap=document.getElementById('pg-btns');if(!wrap)return;
9435      var btns=[];
9436      function mkBtn(label,page,active,disabled){{
9437        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
9438        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
9439      }}
9440      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
9441      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
9442      if(s>1)btns.push(mkBtn('1',1,false,false));
9443      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
9444      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
9445      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
9446      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
9447      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
9448      wrap.innerHTML=btns.join('');
9449      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
9450        b.addEventListener('click',function(){{
9451          var pg=parseInt(this.dataset.pg,10);
9452          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
9453        }});
9454      }});
9455    }}
9456
9457    // Tab filter
9458    document.querySelectorAll('.tab-btn').forEach(function(btn){{
9459      btn.addEventListener('click',function(){{
9460        activeStatus=this.dataset.status||'';
9461        currentPage=1;
9462        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9463        this.classList.add('active');
9464        renderFilePage();
9465      }});
9466    }});
9467
9468    // Per-page selector
9469    var ppSel=document.getElementById('per-page-sel');
9470    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
9471
9472    // ── Column header sort ───────────────────────────────────────────────────
9473    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
9474      th.addEventListener('click',function(){{
9475        var col=th.dataset.sortCol;
9476        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
9477        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9478          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
9479        }});
9480        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
9481        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
9482        currentPage=1;renderFilePage();
9483      }});
9484    }});
9485
9486    // Reset button also clears sort
9487    var mcResetBtn=document.getElementById('mc-file-reset-btn');
9488    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
9489      mcSortCol=null;mcSortAsc=true;
9490      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9491        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
9492      }});
9493      activeStatus='';currentPage=1;
9494      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9495      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
9496      renderFilePage();
9497    }});
9498
9499    renderFilePage();
9500
9501    // ── CSV export ───────────────────────────────────────────────────────────
9502    var exportBtn=document.getElementById('export-csv-btn');
9503    if(exportBtn)exportBtn.addEventListener('click',function(){{
9504      var header=['File','Language','Status'];
9505      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
9506      header.push('Net Delta');
9507      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
9508      var filtered=getFiltered();
9509      filtered.forEach(function(f){{
9510        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
9511        for(var j=0;j<N;j++){{
9512          cols.push(f.c[j]!=null?f.c[j]:'');
9513          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
9514        }}
9515        cols.push(f.t);
9516        rows.push(cols.join(','));
9517      }});
9518      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
9519      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9520      a.download=mcExportName('csv');a.click();
9521    }});
9522
9523    // ── File matrix extra export buttons ─────────────────────────────────────
9524    (function(){{
9525      var resetBtn=document.getElementById('mc-file-reset-btn');
9526      if(resetBtn)resetBtn.addEventListener('click',function(){{
9527        activeStatus='';currentPage=1;
9528        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9529        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
9530        renderFilePage();
9531      }});
9532
9533      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
9534      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
9535      function mcMakeXlsx(fname){{
9536        var filtered=getFiltered();
9537        var enc=new TextEncoder();
9538        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;}}
9539        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;}}
9540        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
9541        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
9542        var ss=[],si={{}};
9543        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
9544        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9545        function WS(){{
9546          var R=0,buf=[];
9547          function cl(c){{return String.fromCharCode(65+c);}}
9548          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
9549          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
9550          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
9551          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>';}}
9552          return{{sc:sc,nc:nc,row:row,xml:xml}};
9553        }}
9554        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
9555        var proj=mcExportProj();
9556        // \u2500\u2500 Summary sheet \u2500\u2500
9557        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9558        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
9559        r1(s1(0,proj,2));
9560        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
9561        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
9562        r1('');
9563        r1(s1(0,'SCAN SUMMARY',8));
9564        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));
9565        POINTS.forEach(function(p,i){{
9566          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
9567          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));
9568        }});
9569        r1('');
9570        if(POINTS.length>1){{
9571          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
9572          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
9573          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
9574          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)));}};
9575          nr('Code Lines',pf.code,pl.code);
9576          nr('Comment Lines',pf.comments,pl.comments);
9577          nr('Files Analyzed',pf.files,pl.files);
9578          nr('Tests',pf.tests,pl.tests);
9579          r1('');
9580        }}
9581        var cMod=0,cAdd=0,cRem=0,cUnch=0;
9582        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
9583        var totF=FILES.length||1;
9584        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
9585        r1(s1(0,'FILE CHANGES',8));
9586        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
9587        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
9588        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
9589        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
9590        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
9591        var lm={{}};
9592        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;}});
9593        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
9594        if(langs.length){{
9595          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9596          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
9597          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)));}});
9598        }}
9599        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
9600        // \u2500\u2500 File Delta sheet \u2500\u2500
9601        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9602        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
9603        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);}}
9604        hcells+=s2(hc,'Net Delta',3);
9605        r2(hcells);
9606        filtered.forEach(function(f){{
9607          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
9608          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));}}}}
9609          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
9610          r2(cells);
9611        }});
9612        var ncols=3+N+(N-1)+1;
9613        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
9614        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>';
9615        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9616        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>',
9617          '_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>',
9618          '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>',
9619          '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>',
9620          '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>',
9621          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
9622        var zparts=[],zcds=[],zoff=0,znf=0;
9623        ['[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){{
9624          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9625          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]);
9626          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);
9627          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));
9628          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
9629          zoff+=entry.length;znf++;
9630        }});
9631        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
9632        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]);
9633        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
9634        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9635        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9636        out.set(new Uint8Array(eocd),pos);
9637        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
9638        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9639      }}
9640
9641      var xlsBtn=document.getElementById('mc-file-xls-btn');
9642      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
9643
9644      // File matrix HTML export — interactive: sort by column, filter by status
9645      function mcFileBuildHtml(){{
9646        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9647        var hdrs=['File','Language','Status'];
9648        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
9649        hdrs.push('Net \u0394');
9650        var SI=2;
9651        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;}});
9652        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
9653        var cnt={{all:allRows.length}};
9654        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
9655        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
9656        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
9657          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9658          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9659          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
9660          '.sub{{font-size:12px;color:#99aabb;}}'+
9661          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
9662          '.wr{{padding:16px 20px;}}'+
9663          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
9664          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
9665          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
9666          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
9667          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
9668          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
9669          'thead tr{{background:#1a2035;}}'+
9670          '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;}}'+
9671          'th:hover{{background:#2a3050;}}'+
9672          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
9673          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
9674          'tr:nth-child(even) td{{background:#faf7f4;}}'+
9675          'tr:hover td{{background:#f5f0ea;}}'+
9676          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
9677          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
9678        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
9679        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
9680          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
9681          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
9682          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
9683          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
9684        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
9685          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
9686          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
9687          '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>";}}'+
9688          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
9689          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
9690          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
9691          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
9692          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
9693          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
9694          '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("")'+
9695          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
9696          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
9697          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
9698          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
9699          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
9700          'sd=(sc===ci)?-sd:1;sc=ci;'+
9701          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
9702          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
9703          'render();';
9704        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
9705          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
9706          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
9707          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
9708          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
9709          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
9710          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
9711          '<script>'+inlineJs+'<\/script><\/body><\/html>';
9712      }}
9713
9714      var htmlBtn=document.getElementById('mc-file-html-btn');
9715      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
9716        var h=mcFileBuildHtml();
9717        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
9718        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9719        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9720      }});
9721
9722      var pdfBtn=document.getElementById('mc-file-pdf-btn');
9723      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
9724        var btn=pdfBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
9725        var h=mcBuildPdfHtml();
9726        fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:h,filename:mcExportName('files.pdf')}})}})
9727          .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
9728          .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);}})
9729          .catch(function(e){{alert('PDF export failed: '+e.message);}})
9730          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
9731      }});
9732    }})();
9733
9734    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
9735    (function(){{
9736      var OX='#C45C10',GN='#2A6846',RD='#B23030',LGY='#DDDDDD';
9737      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9738      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();}}
9739      function px(n){{return Math.round(n);}}
9740      var _tt=document.getElementById('mc-ic-tt');
9741      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
9742      function addTT(el){{
9743        if(!el)return;
9744        el.addEventListener('mouseover',function(e){{
9745          var t=e.target.closest('[data-ttl]');
9746          if(t&&_tt){{
9747            var ttl=t.getAttribute('data-ttl');
9748            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
9749            _tt.style.display='block';mvTT(e);
9750            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9751            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
9752          }} else {{
9753            if(_tt)_tt.style.display='none';
9754            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9755          }}
9756        }});
9757        el.addEventListener('mouseleave',function(){{
9758          if(_tt)_tt.style.display='none';
9759          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9760        }});
9761        el.addEventListener('mousemove',function(e){{mvTT(e);}});
9762      }}
9763      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';}}
9764      if(N<2)return;
9765      var p0=POINTS[0],pLast=POINTS[N-1];
9766      // Chart 1: Code Metrics — Scan 1 vs Latest (grouped bars, same structure as Scan Delta)
9767      var c1mets=[
9768        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:'#93C5FD',cc:'#2563EB'}},
9769        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:'#C4B5FD',cc:'#7C3AED'}},
9770        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:'#6EE7B7',cc:'#0D9488'}}
9771      ];
9772      var maxV1=Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))*1.15||1;
9773      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;
9774      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9775      for(var gi=1;gi<=4;gi++){{
9776        var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
9777        c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
9778        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>';
9779      }}
9780      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9781      c1+='<text x="'+(c1ml-5)+'" y="'+(c1mt+c1ph+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">0</text>';
9782      c1mets.forEach(function(m,i){{
9783        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9784        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9785        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>';
9786        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;"/>';
9787        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>';
9788        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;"/>';
9789        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>';
9790        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>';
9791        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>';
9792      }});
9793      c1+='</svg>';
9794      // Chart 2: Delta by Metric (net delta first scan to last)
9795      var mets=[
9796        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#2563EB'}},
9797        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#7C3AED'}},
9798        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:'#0D9488'}}
9799      ];
9800      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
9801      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;
9802      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9803      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9804      mets.forEach(function(m,i){{
9805        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);
9806        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>';
9807        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;"/>';
9808        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>';}}
9809        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>';}}
9810      }});
9811      c2+='</svg>';
9812      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
9813      var lm={{}};
9814      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;}});
9815      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
9816      var c3='';
9817      if(langs.length){{
9818        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
9819        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;
9820        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9821        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9822        langs.forEach(function(l,i){{
9823          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);
9824          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9825          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"/>';
9826          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>';}}
9827          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>';}}
9828          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>';
9829        }});
9830        c3+='</svg>';
9831      }}
9832      // Chart 4: File Change Distribution (centered donut, legend below)
9833      var fm=0,fa=0,fr=0,fu=0;
9834      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
9835      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;}});
9836      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
9837      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
9838      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;
9839      if(segs.length===1){{
9840        c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
9841        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface-2)"/>';
9842      }} else {{
9843        segs.forEach(function(s){{
9844          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
9845          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);
9846          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);
9847          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"/>';
9848          ang4+=sw;
9849        }});
9850      }}
9851      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>';
9852      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9853      segs.forEach(function(s,i){{
9854        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
9855        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;"/>';
9856        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>';
9857      }});
9858      c4+='</svg>';
9859      // Inject charts
9860      var e1=document.getElementById('mc-ic-c1');if(e1){{e1.innerHTML=c1;addTT(e1);}}
9861      var e2=document.getElementById('mc-ic-c2');if(e2){{e2.innerHTML=c2;addTT(e2);}}
9862      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);}}
9863      var e4=document.getElementById('mc-ic-c4');if(e4){{e4.innerHTML=c4;addTT(e4);}}
9864      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
9865
9866      // HTML legend hover → highlight matching SVG bars within the SAME card only
9867      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
9868        var metric=leg.getAttribute('data-highlight');
9869        var parentCard=leg.closest('.ic-card');
9870        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
9871        if(!chartEl)return;
9872        leg.addEventListener('mouseenter',function(){{
9873          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
9874            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
9875              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
9876              x.style.opacity='1';
9877            }} else {{
9878              x.style.opacity='0.28';
9879            }}
9880          }});
9881        }});
9882        leg.addEventListener('mouseleave',function(){{
9883          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9884        }});
9885      }});
9886      // Author handles
9887      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
9888
9889      // ── Export helpers ────────────────────────────────────────────────────────
9890      // Fetch one image from the server and return a data-URI Promise
9891      function mcFetchUri(path){{
9892        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
9893          return new Promise(function(res){{
9894            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
9895          }});
9896        }}).catch(function(){{return '';}});
9897      }}
9898      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
9899      function mcInlineImgs(html,cb){{
9900        var paths=[],seen={{}};
9901        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
9902        if(!paths.length){{cb(html);return;}}
9903        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
9904          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
9905          .catch(function(){{cb(html);}});
9906      }}
9907      // Capture full-page HTML with all table rows visible
9908      function mcRawHtml(pdfMode){{
9909        if(pdfMode)document.body.classList.add('pdf-mode');
9910        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
9911        var html=document.documentElement.outerHTML;
9912        perPage=s;currentPage=p;renderFilePage();
9913        if(pdfMode)document.body.classList.remove('pdf-mode');
9914        return html;
9915      }}
9916
9917      // HTML export (full page with inlined images)
9918      function mcDoHtml(btn,fname){{
9919        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
9920        mcInlineImgs(mcRawHtml(false),function(html){{
9921          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
9922          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9923          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9924          btn.disabled=false;btn.innerHTML=orig;
9925        }});
9926      }}
9927      // PDF export — comprehensive document-style report: full numbers, all sections
9928      function mcBuildPdfHtml(){{
9929        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
9930        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
9931        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
9932        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>';}}
9933        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
9934        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
9935        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)));}}
9936        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
9937        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
9938        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
9939        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
9940          '.hdr{{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9941          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9942          '.title{{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}}'+
9943          '.proj{{font-size:12px;color:#99aabb;margin-top:3px;}}'+
9944          '.hr{{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}}'+
9945          '.body{{padding:18px 24px;}}'+
9946          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px;}}'+
9947          '.sc{{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}}'+
9948          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
9949          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
9950          '.sec{{margin-bottom:20px;}}'+
9951          '.sh{{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}}'+
9952          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
9953          'th{{background:#1a2035;color:#fff;padding:5px 8px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;}}'+
9954          'td{{border-bottom:1px solid #eee;padding:4px 8px;vertical-align:middle;}}'+
9955          'tr:nth-child(even) td{{background:#faf8f6;}}'+
9956          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:20px;}}';
9957        // ── Metric Progression ────────────────────────────────────────────────
9958        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
9959        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
9960        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>';
9961        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
9962        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
9963        var progRows=POINTS.map(function(pt,i){{
9964          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)));
9965          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
9966            '<td style="text-align:right">'+full(pt.code)+'</td>'+
9967            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
9968            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
9969            '<td style="text-align:right">'+full(pt.files)+'</td>';
9970          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
9971          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
9972          return r+'</tr>';
9973        }}).join('');
9974        // ── Scan-to-scan changes ──────────────────────────────────────────────
9975        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
9976          var prev=POINTS[i];
9977          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
9978          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
9979          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
9980            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
9981            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
9982            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
9983            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
9984        }}).join(''):'';
9985        // ── File matrix (top 50 by |total delta|) ────────────────────────────
9986        var fmSection='';
9987        if(FILES&&FILES.length){{
9988          // Hard cap on per-scan columns so the table never overflows the page width.
9989          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
9990          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
9991          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
9992          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
9993          fmHdr+='<th style="text-align:right">Total \u0394</th>';
9994          var fmRows=topFiles.map(function(f){{
9995            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
9996            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>';
9997            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
9998            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
9999            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>';
10000          }}).join('');
10001          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
10002          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
10003            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
10004        }}
10005        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
10006          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
10007          '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Multi-Scan Timeline</div><div class="proj">{project_label}</div></div>'+
10008          '<div class="hr">{n} scans<br><span style="color:#7a8b9c">'+commitsList+'</span><br>Generated: '+esc(now)+'</div></div>'+
10009          '<div class="body">'+
10010          '<div class="sg">'+
10011          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
10012            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
10013          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
10014            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
10015          (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>':
10016            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
10017          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
10018          '</div>'+
10019          '<div class="sec"><p class="sh">Metric Progression</p>'+
10020          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
10021          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
10022          '<table><thead><tr><th style="text-align:center">Scans</th>'+
10023          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
10024          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
10025          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
10026          fmSection+
10027          '</div>'+
10028          '<div class="ftr"><span>oxide-sloc v{version}</span><span>Multi-Scan Timeline Report</span><span>{project_label} &middot; {n} scans</span></div>'+
10029          '</body></html>';
10030      }}
10031      function mcDoPdf(btn){{
10032        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
10033        var html=mcBuildPdfHtml();
10034        fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:html,filename:mcExportName('pdf')}})}})
10035          .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
10036          .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);}})
10037          .catch(function(e){{alert('PDF export failed: '+e.message);}})
10038          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
10039      }}
10040
10041      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
10042      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
10043      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
10044      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
10045      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
10046      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
10047      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
10048      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
10049      if(location.protocol==='file:'){{
10050        [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';}}}} );
10051        [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';}}}} );
10052      }}
10053    }})();
10054    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
10055    (function(){{
10056      function $(id){{return document.getElementById(id);}}
10057      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10058      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
10059      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
10060      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
10061      function openModal(idx){{
10062        var ov=$('mc-modal-overlay');if(!ov)return;
10063        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
10064        if(idx<0||idx>=N)return;
10065        var pt=POINTS[idx];
10066        titleEl.textContent='Scan '+(idx+1);
10067        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
10068        subEl.textContent=lbl;
10069        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
10070          '<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>'+
10071          '<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>'+
10072          '<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>'+
10073          '<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>'+
10074          (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>':'')+
10075          (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>':'')+
10076          '</div></div>';
10077        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
10078          (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>':'')+
10079          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
10080          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
10081          (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>':'')+
10082          (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>':'')+
10083          (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>':'')+
10084          (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>':'')+
10085          '<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>'+
10086          '</div>';
10087        var dHtml='';
10088        if(idx>0){{
10089          var prev=POINTS[idx-1];
10090          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
10091          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
10092            '<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>'+
10093            '<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>'+
10094            '<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>'+
10095            '</div></div>';
10096        }}
10097        bodyEl.innerHTML=sHtml+iHtml+dHtml;
10098        ov.classList.add('open');document.body.style.overflow='hidden';
10099      }}
10100      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
10101      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
10102      document.addEventListener('click',function(e){{
10103        if(!e.target||!e.target.closest)return;
10104        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
10105        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
10106        var card=e.target.closest('.mc-card');
10107        if(!card)return;
10108        if(e.target.closest('a'))return;
10109        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
10110        var i=cards.indexOf(card);
10111        if(i>=0)openModal(i);
10112      }});
10113      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
10114      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
10115      var statTip=null;
10116      document.addEventListener('mousemove',function(e){{
10117        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
10118        if(!box){{if(statTip)statTip.style.display='none';return;}}
10119        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
10120        var tip=box.getAttribute('data-tip')||'';
10121        if(statTip.textContent!==tip)statTip.textContent=tip;
10122        statTip.style.display='block';
10123        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
10124        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
10125        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
10126        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
10127      }});
10128      (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');}})();
10129    }})();
10130  }})();
10131  </script>
10132  <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]';
10133  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;}}
10134  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>
10135  <!-- Scan card detail modal -->
10136  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
10137    <div class="mc-modal" id="mc-modal">
10138      <div class="mc-modal-head">
10139        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
10140        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
10141      </div>
10142      <div class="mc-modal-body" id="mc-modal-body"></div>
10143    </div>
10144  </div>
10145</body>
10146</html>"##,
10147        project_label = html_escape(project_label),
10148        n = n,
10149        scan_strip = scan_strip,
10150        mc_strip_class = mc_strip_class,
10151        metrics_thead = metrics_thead,
10152        metrics_tbody = metrics_tbody,
10153        file_col_headers = file_col_headers,
10154        total_files = total_files,
10155        files_modified = files_modified,
10156        files_added = files_added,
10157        files_removed = files_removed,
10158        files_unchanged = files_unchanged,
10159        points_json = points_json,
10160        file_matrix_json = file_matrix_json,
10161        nav_compare_active = nav_compare_active,
10162        version = version,
10163        csp_nonce = csp_nonce,
10164        scope_bar_html = scope_bar_html,
10165        scope_label = scope_label,
10166    )
10167}
10168
10169// ── Trend report page ─────────────────────────────────────────────────────────
10170// Protected. Interactive time-series chart page that loads scan history via
10171// /api/metrics/history and renders a vanilla-SVG line chart.
10172//
10173// GET /trend-reports
10174
10175#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
10176async fn trend_report_handler(
10177    State(state): State<AppState>,
10178    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10179) -> Response {
10180    auto_scan_watched_dirs(&state).await;
10181
10182    let watched_dirs_list: Vec<String> = {
10183        let wd = state.watched_dirs.lock().await;
10184        wd.dirs.iter().map(|p| p.display().to_string()).collect()
10185    };
10186
10187    // Collect distinct project roots for the root selector dropdown.
10188    let roots: Vec<String> = {
10189        let reg = state.registry.lock().await;
10190        let mut seen = std::collections::BTreeSet::new();
10191        reg.entries
10192            .iter()
10193            .flat_map(|e| e.input_roots.iter().cloned())
10194            .filter(|r| seen.insert(r.clone()))
10195            .collect()
10196    };
10197
10198    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
10199    let nonce = &csp_nonce;
10200    let version = env!("CARGO_PKG_VERSION");
10201
10202    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
10203    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
10204    // of interactive controls — folder watching is managed by the host administrator.
10205    let watched_dirs_html: String = if state.server_mode {
10206        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()
10207    } else {
10208        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
10209            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
10210                .to_string()
10211        } else {
10212            watched_dirs_list
10213                .iter()
10214                .fold(String::new(), |mut s, d| {
10215                    use std::fmt::Write as _;
10216                    let escaped =
10217                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
10218                    write!(
10219                        s,
10220                        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>"#
10221                    ).expect("write to String is infallible");
10222                    s
10223                })
10224        };
10225        format!(
10226            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>"#
10227        )
10228    };
10229
10230    let html = format!(
10231        r##"<!doctype html>
10232<html lang="en">
10233<head>
10234  <meta charset="utf-8" />
10235  <meta name="viewport" content="width=device-width, initial-scale=1" />
10236  <title>OxideSLOC | Trend Reports</title>
10237  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238  <style nonce="{nonce}">
10239    :root {{
10240      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10241      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10242      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
10243      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10244      --info-bg:#eef3ff; --info-text:#4467d8;
10245    }}
10246    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
10247    *{{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;}}
10248    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10249    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
10250    .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;}}
10251    @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));}}}}
10252    .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);}}
10253    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
10254    .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));}}
10255    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256    .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;}}
10257    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
10258    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
10259    @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; }} }}
10260    .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;}}
10261    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10262    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
10265    .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;}}
10266    .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;}}
10267    .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;}}
10268    .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;}}
10269    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10270    .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);}}
10271    .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;}}
10272    .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;}}
10273    .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;}}
10274    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10275    .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;}}
10276    .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);}}
10277    .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;}}
10278    .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;}}
10279    .tz-select:focus{{border-color:var(--oxide);}}
10280    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10281    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
10282    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
10283    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
10284    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
10285    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
10286    .trend-title-block{{flex:1;min-width:0;}}
10287    .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;}}
10288    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
10289    .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;}}
10290    .chart-select:focus{{border-color:var(--accent);}}
10291    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
10292    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
10293    .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;}}
10294    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
10295    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
10296    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
10297    .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);}}
10298    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
10299    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
10300    .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;}}
10301    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
10302    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
10303    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
10304    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
10305    .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;}}
10306    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
10307    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
10308    .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);}}
10309    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
10310    .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;}}
10311    .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;}}
10312    .data-table tr:last-child td{{border-bottom:none;}}
10313    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
10314    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
10315    .table-wrap{{width:100%;overflow-x:auto;}}
10316    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
10317    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
10318    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10319    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
10320    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
10321    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
10322    .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;}}
10323    .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;}}
10324    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10325    .pagination-info{{font-size:13px;color:var(--muted);}}
10326    .pagination-btns{{display:flex;gap:6px;}}
10327    .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;}}
10328    .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;}}
10329    #scan-history-table col:nth-child(1){{width:155px;}}
10330    #scan-history-table col:nth-child(2){{width:240px;}}
10331    #scan-history-table col:nth-child(3){{width:82px;}}
10332    #scan-history-table col:nth-child(4){{width:82px;}}
10333    #scan-history-table col:nth-child(5){{width:90px;}}
10334    #scan-history-table col:nth-child(6){{width:90px;}}
10335    #scan-history-table col:nth-child(7){{width:88px;}}
10336    #scan-history-table col:nth-child(8){{width:150px;}}
10337    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
10338    .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;}}
10339    .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;}}
10340    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
10341    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
10342    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
10343    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
10344    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
10345    .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;}}
10346    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
10347    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
10348    .watched-chip-rm:hover{{color:var(--oxide);}}
10349    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
10350    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
10351    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
10352    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
10353    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
10354    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
10355    a.run-link:hover{{text-decoration:underline;}}
10356    .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);}}
10357    .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);}}
10358    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
10359    .metric-num{{font-weight:700;color:var(--text);}}
10360    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
10361    .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;}}
10362    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10363    .btn.primary:hover{{opacity:.9;}}
10364    .rpt-btn{{min-width:58px;justify-content:center;}}
10365    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
10366    .report-cell{{overflow:visible!important;white-space:normal!important;}}
10367    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
10368    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
10369    .submod-details summary::-webkit-details-marker{{display:none;}}
10370    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
10371    .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;}}
10372    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
10373    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
10374    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
10375    .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;}}
10376    .export-btn:hover{{background:var(--line);}}
10377    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
10378    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10379    .site-footer a{{color:var(--muted);}}
10380    .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;}}
10381    .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;}}
10382    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
10383  </style>
10384</head>
10385<body>
10386  <div class="background-watermarks" aria-hidden="true">
10387    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10388    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10389    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10390    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10391    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10393  </div>
10394  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10395  <div class="top-nav">
10396    <div class="top-nav-inner">
10397      <a class="brand" href="/">
10398        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10399        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
10400      </a>
10401      <div class="nav-right">
10402        <a class="nav-pill" href="/">Home</a>
10403        <div class="nav-dropdown">
10404          <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>
10405          <div class="nav-dropdown-menu">
10406            <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>
10407          </div>
10408        </div>
10409        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10410        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10411        <div class="nav-dropdown">
10412          <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>
10413          <div class="nav-dropdown-menu">
10414            <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>
10415          </div>
10416        </div>
10417        <div class="server-status-wrap" id="server-status-wrap">
10418          <div class="nav-pill server-online-pill" id="server-status-pill">
10419            <span class="status-dot" id="status-dot"></span>
10420            <span id="server-status-label">Server</span>
10421            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10422          </div>
10423          <div class="server-status-tip">
10424            OxideSLOC is running — accessible on your network.
10425            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10426          </div>
10427        </div>
10428        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10429          <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>
10430        </button>
10431        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10432          <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>
10433          <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>
10434        </button>
10435      </div>
10436    </div>
10437  </div>
10438
10439  <div class="page">
10440    {watched_dirs_html}
10441    <div class="summary-strip" id="trend-stats"></div>
10442    <div class="panel">
10443      <div class="trend-header">
10444        <div class="trend-title-block">
10445          <h1>Trend Reports</h1>
10446          <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>
10447          <span class="chart-hint-inline">
10448            <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>
10449            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
10450          </span>
10451        </div>
10452        <div class="chart-actions">
10453          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
10454            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
10455            Retention Policy
10456          </button>
10457          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
10458            <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>
10459            Clean up old runs
10460          </button>
10461          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
10462            <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>
10463            Export Excel
10464          </button>
10465          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
10466            <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>
10467            Export PNG
10468          </button>
10469        </div>
10470      </div>
10471
10472      <div class="controls-centered">
10473        <label>Project Root:
10474          <select class="chart-select" id="root-sel">
10475            <option value="">All projects</option>
10476          </select>
10477        </label>
10478        <label>Y Metric:
10479          <select class="chart-select" id="y-sel">
10480            <option value="code_lines">Code Lines</option>
10481            <option value="comment_lines">Comment Lines</option>
10482            <option value="blank_lines">Blank Lines</option>
10483            <option value="physical_lines">Physical Lines</option>
10484            <option value="files_analyzed">Files Analyzed</option>
10485          </select>
10486        </label>
10487        <label>X Axis:
10488          <select class="chart-select" id="x-sel">
10489            <option value="time">By Time</option>
10490            <option value="commit">By Commit</option>
10491            <option value="release">By Release</option>
10492            <option value="tag">Tagged Commits</option>
10493          </select>
10494        </label>
10495        <label id="submodule-label" style="display:none;">Submodule:
10496          <select class="chart-select" id="sub-sel">
10497            <option value="">All (project total)</option>
10498          </select>
10499        </label>
10500        <label>Chart Size:
10501          <select class="chart-select" id="scale-sel">
10502            <option value="0.75">Compact</option>
10503            <option value="1.2" selected>Normal</option>
10504            <option value="1.38">Large</option>
10505          </select>
10506        </label>
10507      </div>
10508
10509      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
10510      <div id="data-table-wrap" style="overflow-x:auto;"></div>
10511    </div>
10512  </div>
10513
10514  <script nonce="{nonce}">
10515    (function() {{
10516      // Theme persistence
10517      var b = document.body;
10518      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10519      var tgl = document.getElementById('theme-toggle');
10520      if (tgl) tgl.addEventListener('click', function() {{
10521        var d = b.classList.toggle('dark-theme');
10522        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10523      }});
10524
10525      // Watermark randomizer
10526      (function() {{
10527        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10528        if (!wms.length) return;
10529        var placed = [];
10530        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;}}
10531        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];}}
10532        var half=Math.floor(wms.length/2);
10533        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;}});
10534      }})();
10535
10536      // Code particles
10537      (function() {{
10538        var container = document.getElementById('code-particles');
10539        if (!container) return;
10540        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'];
10541        for (var i = 0; i < 38; i++) {{
10542          (function(idx) {{
10543            var el = document.createElement('span');
10544            el.className = 'code-particle';
10545            el.textContent = snippets[idx % snippets.length];
10546            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10547            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10548            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10549            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';
10550            container.appendChild(el);
10551          }})(i);
10552        }}
10553      }})();
10554
10555      // Watched folder picker
10556      (function() {{
10557        var btn = document.getElementById('add-watched-btn');
10558        if (!btn) return;
10559        btn.addEventListener('click', function() {{
10560          fetch('/pick-directory?kind=reports')
10561            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10562            .then(function(data) {{
10563              if (!data.cancelled && data.selected_path) {{
10564                var form = document.createElement('form');
10565                form.method = 'POST';
10566                form.action = '/watched-dirs/add';
10567                var ri = document.createElement('input');
10568                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10569                var fi = document.createElement('input');
10570                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10571                form.appendChild(ri); form.appendChild(fi);
10572                document.body.appendChild(form);
10573                form.submit();
10574              }}
10575            }})
10576            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10577        }});
10578      }})();
10579
10580      // Settings / color-scheme modal
10581      (function() {{
10582        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'}}];
10583        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);}});}}
10584        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10585        var btn=document.getElementById('settings-btn');if(!btn)return;
10586        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10587        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>';
10588        document.body.appendChild(m);
10589        var g=document.getElementById('scheme-grid');
10590        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);}});
10591        var cl=document.getElementById('settings-close');
10592        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);
10593        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');}});
10594        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10595        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10596      }})();
10597    }})();
10598
10599    var ROOTS = {roots_json};
10600    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
10601    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
10602    var allData = [];
10603
10604    // Populate root selector
10605    var rootSel = document.getElementById('root-sel');
10606    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
10607
10608    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();}}
10609    function fmtFull(n){{return Number(n).toLocaleString();}}
10610    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
10611
10612    // Tooltip
10613    var tt = document.createElement('div');
10614    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);';
10615    document.body.appendChild(tt);
10616    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
10617    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';}}
10618    function hideTT(){{tt.style.display='none';}}
10619    window.addEventListener('blur',function(){{hideTT();}});
10620    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
10621
10622    function statExact(compact, full){{
10623      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
10624    }}
10625    function statVal(n){{
10626      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
10627    }}
10628
10629    function updateStats(data){{
10630      var statsEl=document.getElementById('trend-stats');
10631      if(!statsEl)return;
10632      if(!data||!data.length){{statsEl.innerHTML='';return;}}
10633      var yKey=document.getElementById('y-sel').value;
10634      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10635      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10636      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
10637      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
10638      var absDelta=Math.abs(delta);
10639      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
10640      var deltaExact=statExact(deltaCompact,deltaFull);
10641      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
10642      statsEl.innerHTML=
10643        '<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>'+
10644        '<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>'+
10645        '<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>'+
10646        '<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>';
10647    }}
10648
10649    var subSel = document.getElementById('sub-sel');
10650    var subLabel = document.getElementById('submodule-label');
10651
10652    function populateSubmodules(root){{
10653      if(!subSel||!subLabel)return;
10654      while(subSel.options.length>1)subSel.remove(1);
10655      subSel.value='';
10656      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
10657      fetch(url)
10658        .then(function(r){{return r.json();}})
10659        .then(function(subs){{
10660          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
10661          subs.forEach(function(s){{
10662            var o=document.createElement('option');
10663            o.value=s.name;
10664            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
10665            subSel.appendChild(o);
10666          }});
10667          subLabel.style.display='';
10668        }})
10669        .catch(function(){{subLabel.style.display='none';}});
10670    }}
10671
10672    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
10673
10674    function loadAndRender(){{
10675      var root = rootSel.value;
10676      var sub = subSel ? subSel.value : '';
10677      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
10678      document.getElementById('data-table-wrap').innerHTML='';
10679      var url = '/api/metrics/history?limit=100'
10680        + (root ? '&root='+encodeURIComponent(root) : '')
10681        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
10682      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
10683        allData = data;
10684        render(data);
10685        updateStats(data);
10686      }}).catch(function(){{
10687        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>';
10688      }});
10689    }}
10690
10691    function render(data){{
10692      var yKey = document.getElementById('y-sel').value;
10693      var xMode = document.getElementById('x-sel').value;
10694
10695      // Filter for tag/release mode
10696      var pts = data;
10697      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
10698
10699      // Sort oldest-first for the line chart
10700      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10701
10702      var wrap = document.getElementById('chart-wrap');
10703      if(!pts.length){{
10704        var emptyMsg = (xMode === 'tag')
10705          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
10706          : 'No scan data found for the selected filters.';
10707        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
10708        renderTable([]);
10709        return;
10710      }}
10711
10712      var scaleEl=document.getElementById('scale-sel');
10713      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
10714      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;
10715      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
10716
10717      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10718
10719      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">';
10720      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>';
10721
10722      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
10723
10724      // Grid + Y axis ticks
10725      for(var ti=0;ti<=5;ti++){{
10726        var gy=PT+CH-Math.round(ti/5*CH);
10727        var gv=Math.round(ti/5*maxY);
10728        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
10729        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
10730      }}
10731
10732      // X axis labels (every N-th point to avoid crowding)
10733      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
10734      pts.forEach(function(d,i){{
10735        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10736        if(i%labelEvery===0||i===pts.length-1){{
10737          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)));
10738          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>';
10739        }}
10740      }});
10741
10742      // Axis label
10743      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
10744      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>';
10745      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>';
10746
10747      // Area fill + line path
10748      var pathD='';
10749      pts.forEach(function(d,i){{
10750        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10751        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10752        pathD+=(i===0?'M':'L')+x+','+y;
10753      }});
10754      if(pts.length>1){{
10755        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
10756        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
10757      }}
10758      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
10759
10760      // Data points (clickable) + permanent value labels
10761      var showLabels = pts.length <= 40;
10762      var labelEveryN = pts.length > 20 ? 2 : 1;
10763      pts.forEach(function(d,i){{
10764        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10765        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10766        var hasTags=d.tags&&d.tags.length>0;
10767        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
10768        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
10769        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+'"/>';
10770        if(showLabels && i%labelEveryN===0){{
10771          var lx=x, ly=y-r-5;
10772          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>';
10773        }}
10774      }});
10775
10776      svg+='</svg>';
10777      wrap.innerHTML=svg;
10778
10779      // Attach point tooltips
10780      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
10781        c.addEventListener('mouseover',function(e){{
10782          var d=pts[parseInt(this.dataset.idx)];
10783          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(''):'';
10784          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>':'';
10785          showTT(e,
10786            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
10787            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
10788            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
10789            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
10790          );
10791          this.setAttribute('r','8');
10792        }});
10793        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
10794        c.addEventListener('mousemove',moveTT);
10795        c.addEventListener('click',function(){{
10796          var d=pts[parseInt(this.dataset.idx)];
10797          if(d.html_url) window.open(d.html_url,'_blank');
10798        }});
10799      }});
10800
10801      renderTable(pts, yKey);
10802    }}
10803
10804    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
10805    var shProjFilter='', shBranchFilter='';
10806
10807    function fmtPST(isoStr){{
10808      if(!isoStr)return'';
10809      var d=new Date(isoStr);
10810      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
10811      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);}}
10812      function p(n){{return n<10?'0'+n:String(n);}}
10813      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++;}}}}
10814      var yr=d.getUTCFullYear();
10815      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
10816      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
10817      var isDST=d>=dstStart&&d<dstEnd;
10818      var off=isDST?-7*3600*1000:-8*3600*1000;
10819      var lbl=isDST?'PDT':'PST';
10820      var loc=new Date(d.getTime()+off);
10821      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
10822    }}
10823
10824    function getShRows(){{
10825      var proj=shProjFilter.toLowerCase().trim();
10826      var branch=shBranchFilter;
10827      return shData.filter(function(d){{
10828        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
10829        if(branch&&(d.branch||'')!==branch)return false;
10830        return true;
10831      }});
10832    }}
10833
10834    function renderShPage(){{
10835      var filtered=getShRows();
10836      if(shSortCol){{
10837        filtered.sort(function(a,b){{
10838          var va,vb;
10839          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
10840          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
10841          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
10842          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
10843          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
10844          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
10845        }});
10846      }}
10847      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
10848      shPage=Math.min(shPage,totalPages);
10849      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
10850      var visible=filtered.slice(start,end);
10851      var tbody=document.getElementById('sh-tbody');
10852      if(!tbody)return;
10853      tbody.innerHTML=visible.map(function(d){{
10854        var tsHtml=esc(fmtPST(d.timestamp));
10855        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>';
10856        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>';
10857        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
10858        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
10859        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
10860        var reportCell='';
10861        if(d.html_url){{
10862          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
10863          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>';}}
10864          reportCell+='</div>';
10865        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
10866        if(d.submodule_links&&d.submodule_links.length){{
10867          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
10868          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
10869          reportCell+='</div></details>';
10870        }}
10871        return '<tr>'
10872          +'<td>'+tsHtml+'</td>'
10873          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
10874          +'<td>'+runIdHtml+'</td>'
10875          +'<td>'+commitHtml+'</td>'
10876          +'<td>'+branchHtml+'</td>'
10877          +'<td>'+tags+'</td>'
10878          +'<td class="num">'+metricHtml+'</td>'
10879          +'<td class="report-cell">'+reportCell+'</td>'
10880          +'</tr>';
10881      }}).join('');
10882      var pgRange=document.getElementById('sh-pg-range');
10883      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
10884      var pgInfo=document.getElementById('sh-pg-info');
10885      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
10886      var pgBtns=document.getElementById('sh-pg-btns');
10887      if(pgBtns){{
10888        pgBtns.innerHTML='';
10889        function mkPgBtn(lbl,pg,active,disabled){{
10890          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
10891          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
10892          return b;
10893        }}
10894        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
10895        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
10896        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
10897        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
10898      }}
10899    }}
10900
10901    function wireTableBehavior(){{
10902      var pf=document.getElementById('sh-proj-filter');
10903      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
10904      var bf=document.getElementById('sh-branch-filter');
10905      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
10906      var rb=document.getElementById('sh-reset-btn');
10907      if(rb)rb.addEventListener('click',function(){{
10908        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
10909        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
10910        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
10911        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');}});
10912        renderShPage();
10913      }});
10914      var pps=document.getElementById('sh-per-page');
10915      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
10916      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
10917      ths.forEach(function(th){{
10918        th.addEventListener('click',function(e){{
10919          if(e.target.classList.contains('col-resize-handle'))return;
10920          var col=th.dataset.col;
10921          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
10922          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
10923          th.classList.add('sort-'+shSortOrder);
10924          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
10925          shPage=1;renderShPage();
10926        }});
10927      }});
10928      var table=document.getElementById('scan-history-table');
10929      if(!table)return;
10930      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
10931      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
10932      allThs.forEach(function(th,i){{
10933        var handle=th.querySelector('.col-resize-handle');
10934        if(!handle||!cols[i])return;
10935        var startX,startW;
10936        handle.addEventListener('mousedown',function(e){{
10937          e.stopPropagation();e.preventDefault();
10938          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
10939          handle.classList.add('dragging');
10940          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
10941          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
10942          document.addEventListener('mousemove',onMove);
10943          document.addEventListener('mouseup',onUp);
10944        }});
10945      }});
10946    }}
10947
10948    function renderTable(pts, yKey){{
10949      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
10950      var wrap=document.getElementById('data-table-wrap');
10951      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
10952      var yLabel=Y_LABELS[yKey]||yKey||'';
10953      shData=pts.slice().reverse();
10954      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
10955      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
10956      var branches={{}};
10957      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
10958      var branchOpts='<option value="">All branches</option>';
10959      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
10960      wrap.innerHTML=
10961        '<div class="chart-section-header">SCAN HISTORY</div>'+
10962        '<div class="filter-row">'+
10963          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
10964          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
10965          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
10966        '</div>'+
10967        '<div class="table-wrap">'+
10968        '<table id="scan-history-table" class="data-table">'+
10969        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
10970        '<thead><tr id="sh-thead">'+
10971        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
10972        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
10973        '<th>Run ID<div class="col-resize-handle"></div></th>'+
10974        '<th>Commit<div class="col-resize-handle"></div></th>'+
10975        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
10976        '<th>Tags<div class="col-resize-handle"></div></th>'+
10977        '<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>'+
10978        '<th>Report<div class="col-resize-handle"></div></th>'+
10979        '</tr></thead>'+
10980        '<tbody id="sh-tbody"></tbody>'+
10981        '</table>'+
10982        '</div>'+
10983        '<div class="pagination">'+
10984          '<span class="pagination-info" id="sh-pg-info"></span>'+
10985          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
10986          '<div style="display:flex;align-items:center;gap:8px;">'+
10987            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
10988            '<select class="filter-select" id="sh-per-page">'+
10989              '<option value="10">10 per page</option>'+
10990              '<option value="25" selected>25 per page</option>'+
10991              '<option value="50">50 per page</option>'+
10992              '<option value="100">100 per page</option>'+
10993            '</select>'+
10994            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
10995          '</div>'+
10996        '</div>';
10997      wireTableBehavior();
10998      renderShPage();
10999    }}
11000
11001    function exportXLSX(){{
11002      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
11003      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
11004      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
11005      var s1R=sorted.map(function(d){{
11006        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||''];
11007      }});
11008      var pm={{}};
11009      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
11010      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'];
11011      var s2R=Object.keys(pm).map(function(p){{
11012        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11013        var lat=sc[sc.length-1],fst=sc[0];
11014        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
11015        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);
11016        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];
11017      }});
11018      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
11019      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
11020      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
11021      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
11022    }}
11023
11024    function buildXLSX(sheets,chartRows,chartRows2){{
11025      function s2b(s){{return new TextEncoder().encode(s);}}
11026      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
11027      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;}}
11028      function crc32(d){{
11029        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;}}}}
11030        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
11031      }}
11032      function buildSheet(hdr,rows,drawRid,withCtrl){{
11033        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
11034        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
11035        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
11036        x+='<row r="1">';
11037        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
11038        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>';}}
11039        x+='</row>';
11040        rows.forEach(function(row,ri){{
11041          var rn=ri+2;
11042          x+='<row r="'+rn+'">';
11043          row.forEach(function(cell,ci){{
11044            var addr=col2l(ci+1)+rn;
11045            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
11046            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
11047          }});
11048          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>';}}
11049          x+='</row>';
11050        }});
11051        x+='</sheetData>';
11052        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>';}}
11053        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
11054        return x+'</worksheet>';
11055      }}
11056      function buildChartXML(rows){{
11057        var sn="'Scan History'";
11058        var nr=rows.length,er=nr+1;
11059        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'}}];
11060        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11061        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">';
11062        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11063        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11064        sd.forEach(function(s,i){{
11065          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11066          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>';
11067          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11068          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>';
11069          var dlp=(i===2)?'b':'t';
11070          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>';
11071          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11072          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11073          x+='</c:strCache></c:strRef></c:cat>';
11074          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+'"/>';
11075          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11076          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11077        }});
11078        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
11079        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>';
11080        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>';
11081        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11082        return x;
11083      }}
11084      function buildChartXML2(rows){{
11085        var sn="'By Project'";
11086        var nr=rows.length,er=nr+1;
11087        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'}}];
11088        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11089        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">';
11090        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11091        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11092        sd.forEach(function(s,i){{
11093          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11094          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>';
11095          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11096          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>';
11097          var dlp=(i===2)?'b':'t';
11098          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>';
11099          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11100          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11101          x+='</c:strCache></c:strRef></c:cat>';
11102          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+'"/>';
11103          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11104          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11105        }});
11106        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
11107        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>';
11108        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>';
11109        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11110        return x;
11111      }}
11112      function buildChartXML3(rows){{
11113        var sn="'Scan History'";
11114        var nr=rows.length,er=nr+1;
11115        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11116        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">';
11117        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
11118        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11119        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
11120        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>';
11121        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
11122        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>';
11123        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>';
11124        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11125        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11126        x+='</c:strCache></c:strRef></c:cat>';
11127        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+'"/>';
11128        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
11129        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11130        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
11131        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>';
11132        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>';
11133        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>';
11134        return x;
11135      }}
11136      var hasChart=!!(chartRows&&chartRows.length);
11137      var nr=hasChart?chartRows.length:0;
11138      var hasChart2=!!(chartRows2&&chartRows2.length);
11139      var nr2=hasChart2?chartRows2.length:0;
11140      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>';
11141      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"/>';
11142      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
11143      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"/>';}}
11144      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"/>';}}
11145      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
11146      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>';
11147      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
11148      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"/>';}});
11149      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
11150      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>';
11151      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
11152      wbx+='</sheets></workbook>';
11153      var files=[
11154        {{name:'[Content_Types].xml',data:s2b(ct)}},
11155        {{name:'_rels/.rels',data:s2b(dotrels)}},
11156        {{name:'xl/workbook.xml',data:s2b(wbx)}},
11157        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
11158        {{name:'xl/styles.xml',data:s2b(styl)}}
11159      ];
11160      // Chart embedded directly in Scan History (sheet1); By Project is plain
11161      sheets.forEach(function(s,i){{
11162        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)))}});
11163      }});
11164      if(hasChart){{
11165        var fromRow=nr+4,toRow=nr+24;
11166        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>')}});
11167        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11168        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">';
11169        drx+='<xdr:twoCellAnchor editAs="twoCell">';
11170        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>';
11171        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>';
11172        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11173        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11174        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11175        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11176        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
11177        var focRow=toRow+2,focRowEnd=toRow+22;
11178        drx+='<xdr:twoCellAnchor editAs="twoCell">';
11179        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>';
11180        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>';
11181        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11182        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11183        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11184        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
11185        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
11186        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
11187        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>')}});
11188        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
11189        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
11190      }}
11191      if(hasChart2){{
11192        var fromRow2=nr2+4,toRow2=nr2+24;
11193        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>')}});
11194        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11195        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">';
11196        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
11197        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>';
11198        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>';
11199        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11200        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11201        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11202        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11203        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
11204        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
11205        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>')}});
11206        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
11207      }}
11208      var parts=[],offsets=[],total=0;
11209      files.forEach(function(f){{
11210        offsets.push(total);
11211        var nb=s2b(f.name),crc=crc32(f.data);
11212        var h=new DataView(new ArrayBuffer(30+nb.length));
11213        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
11214        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
11215        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
11216        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
11217        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
11218        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
11219        total+=30+nb.length+f.data.length;
11220      }});
11221      var cdStart=total;
11222      files.forEach(function(f,fi){{
11223        var nb=s2b(f.name),crc=crc32(f.data);
11224        var cd=new DataView(new ArrayBuffer(46+nb.length));
11225        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
11226        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
11227        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
11228        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
11229        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
11230        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
11231        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
11232      }});
11233      var cdSz=total-cdStart;
11234      var eocd=new DataView(new ArrayBuffer(22));
11235      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
11236      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
11237      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
11238      parts.push(new Uint8Array(eocd.buffer));
11239      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
11240      var out=new Uint8Array(sz);var off=0;
11241      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
11242      return out.buffer;
11243    }}
11244
11245    function exportPNG(){{
11246      var svgEl=document.querySelector('#chart-wrap svg');
11247      if(!svgEl){{alert('No chart to export yet.');return;}}
11248      var svgStr=new XMLSerializer().serializeToString(svgEl);
11249      var vb=svgEl.viewBox.baseVal,scale=2;
11250      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
11251      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
11252      var url=URL.createObjectURL(blob);
11253      var img=new Image();
11254      img.onload=function(){{
11255        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
11256        var ctx=canvas.getContext('2d');
11257        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
11258        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
11259        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
11260        URL.revokeObjectURL(url);
11261        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
11262      }};
11263      img.src=url;
11264    }}
11265
11266    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
11267      var el=document.getElementById(id);
11268      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
11269    }});
11270    rootSel.addEventListener('change',function(){{
11271      populateSubmodules(rootSel.value);
11272      loadAndRender();
11273    }});
11274    if(subSel)subSel.addEventListener('change',loadAndRender);
11275
11276    var xlsxBtn=document.getElementById('export-xlsx-btn');
11277    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
11278    var pngBtn=document.getElementById('export-png-btn');
11279    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
11280
11281    // ── Clean-up modal ───────────────────────────────────────────────────────
11282    (function(){{
11283      var triggerBtn=document.getElementById('cleanup-runs-btn');
11284      if(!triggerBtn)return;
11285      var modal=document.createElement('div');
11286      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;';
11287      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);">'
11288        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
11289        +'<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>'
11290        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
11291        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
11292        +'<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;">'
11293        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
11294        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
11295        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
11296        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
11297        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
11298        +'</div></div>';
11299      document.body.appendChild(modal);
11300      triggerBtn.addEventListener('click',function(){{
11301        document.getElementById('cleanup-status').style.display='none';
11302        modal.style.display='flex';
11303      }});
11304      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
11305      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11306      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
11307        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
11308        var confirmBtn=this;
11309        confirmBtn.disabled=true;
11310        var status=document.getElementById('cleanup-status');
11311        status.style.display='block';
11312        status.style.background='#dbeafe';status.style.color='#1e40af';
11313        status.textContent='Deleting\u2026';
11314        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
11315        .then(function(resp){{
11316          return resp.json().then(function(d){{
11317            if(resp.ok){{
11318              status.style.background='#dcfce7';status.style.color='#166534';
11319              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
11320              setTimeout(function(){{window.location.reload();}},1500);
11321            }}else{{
11322              status.style.background='#fee2e2';status.style.color='#991b1b';
11323              status.textContent='Error: '+(d.error||'Unexpected error');
11324              confirmBtn.disabled=false;
11325            }}
11326          }});
11327        }})
11328        .catch(function(e){{
11329          status.style.background='#fee2e2';status.style.color='#991b1b';
11330          status.textContent='Network error: '+String(e);
11331          confirmBtn.disabled=false;
11332        }});
11333      }});
11334    }})();
11335
11336    // ── Retention policy panel ────────────────────────────────────────────────
11337    (function(){{
11338      var triggerBtn=document.getElementById('retention-policy-btn');
11339      if(!triggerBtn)return;
11340      var modal=document.createElement('div');
11341      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;';
11342      modal.innerHTML=''
11343        +'<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);">'
11344        +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
11345        +'<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>'
11346        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
11347        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
11348        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
11349        +'</div>'
11350        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
11351        +'<div>'
11352        +'<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>'
11353        +'<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;">'
11354        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
11355        +'</div>'
11356        +'<div>'
11357        +'<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>'
11358        +'<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;">'
11359        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
11360        +'</div>'
11361        +'</div>'
11362        +'<div style="margin-bottom:20px;">'
11363        +'<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>'
11364        +'<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;">'
11365        +'<option value="1">Every hour</option>'
11366        +'<option value="6">Every 6 hours</option>'
11367        +'<option value="12">Every 12 hours</option>'
11368        +'<option value="24" selected>Every 24 hours</option>'
11369        +'<option value="48">Every 2 days</option>'
11370        +'<option value="72">Every 3 days</option>'
11371        +'<option value="168">Every week</option>'
11372        +'</select>'
11373        +'</div>'
11374        +'<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>'
11375        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
11376        +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
11377        +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
11378        +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
11379        +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
11380        +'</div>'
11381        +'</div>';
11382      document.body.appendChild(modal);
11383
11384      function rpShowStatus(msg,ok){{
11385        var s=document.getElementById('rp-status');
11386        s.style.display='block';
11387        s.style.background=ok?'#dcfce7':'#fee2e2';
11388        s.style.color=ok?'#166534':'#991b1b';
11389        s.textContent=msg;
11390      }}
11391      function fmtAgo(iso){{
11392        if(!iso)return'Never';
11393        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
11394        if(diff<60)return diff+'s ago';
11395        if(diff<3600)return Math.floor(diff/60)+'m ago';
11396        if(diff<86400)return Math.floor(diff/3600)+'h ago';
11397        return Math.floor(diff/86400)+'d ago';
11398      }}
11399      function loadPolicy(){{
11400        fetch('/api/cleanup-policy')
11401          .then(function(r){{return r.json();}})
11402          .then(function(d){{
11403            var p=d.policy;
11404            document.getElementById('rp-enabled').checked=p?p.enabled:false;
11405            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
11406            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
11407            var sel=document.getElementById('rp-interval');
11408            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;}}}}}}
11409            var lr=document.getElementById('rp-last-run');
11410            if(d.last_run_at){{
11411              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'):'');
11412            }}else{{
11413              lr.textContent='Auto-cleanup has not run yet.';
11414            }}
11415          }})
11416          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
11417      }}
11418
11419      triggerBtn.addEventListener('click',function(){{
11420        document.getElementById('rp-status').style.display='none';
11421        loadPolicy();
11422        modal.style.display='flex';
11423      }});
11424      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
11425      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11426
11427      document.getElementById('rp-save-btn').addEventListener('click',function(){{
11428        var enabled=document.getElementById('rp-enabled').checked;
11429        var ageVal=document.getElementById('rp-max-age').value.trim();
11430        var countVal=document.getElementById('rp-max-count').value.trim();
11431        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
11432        if(enabled&&!ageVal&&!countVal){{
11433          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
11434          return;
11435        }}
11436        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
11437        var saveBtn=document.getElementById('rp-save-btn');
11438        saveBtn.disabled=true;
11439        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
11440          .then(function(r){{
11441            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
11442            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
11443          }})
11444          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11445          .finally(function(){{saveBtn.disabled=false;}});
11446      }});
11447
11448      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
11449        var btn=this;
11450        btn.disabled=true;
11451        btn.textContent='Running\u2026';
11452        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
11453          .then(function(r){{return r.json();}})
11454          .then(function(d){{
11455            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
11456            loadPolicy();
11457          }})
11458          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11459          .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
11460      }});
11461    }})();
11462
11463    populateSubmodules(rootSel.value);
11464    loadAndRender();
11465
11466    (function randomizeWatermarks() {{
11467      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11468      if (!wms.length) return;
11469      var placed = [];
11470      function tooClose(top, left) {{
11471        for (var i = 0; i < placed.length; i++) {{
11472          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
11473          if (dt < 16 && dl < 12) return true;
11474        }}
11475        return false;
11476      }}
11477      function pick(leftBand) {{
11478        for (var attempt = 0; attempt < 50; attempt++) {{
11479          var top = Math.random() * 88 + 2;
11480          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11481          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
11482        }}
11483        var top = Math.random() * 88 + 2;
11484        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11485        placed.push([top, left]); return [top, left];
11486      }}
11487      var half = Math.floor(wms.length / 2);
11488      wms.forEach(function (img, i) {{
11489        var pos = pick(i < half);
11490        var size = Math.floor(Math.random() * 100 + 120);
11491        var rot = (Math.random() * 360).toFixed(1);
11492        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
11493        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;
11494      }});
11495    }})();
11496    (function spawnCodeParticles() {{
11497      var container = document.getElementById('code-particles');
11498      if (!container) return;
11499      var snippets = [
11500        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
11501        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
11502        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
11503        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
11504        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
11505      ];
11506      var count = 38;
11507      for (var i = 0; i < count; i++) {{
11508        (function(idx) {{
11509          var el = document.createElement('span');
11510          el.className = 'code-particle';
11511          el.textContent = snippets[idx % snippets.length];
11512          var left = Math.random() * 94 + 2;
11513          var top = Math.random() * 88 + 6;
11514          var dur = (Math.random() * 10 + 9).toFixed(1);
11515          var delay = (Math.random() * 18).toFixed(1);
11516          var rot = (Math.random() * 26 - 13).toFixed(1);
11517          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11518          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
11519          container.appendChild(el);
11520        }})(i);
11521      }}
11522    }})();
11523  </script>
11524  <footer class="site-footer">
11525    local code analysis - metrics, history and reports
11526    &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>
11527    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11528    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11529    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11530    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
11531  </footer>
11532  <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>
11533</body>
11534</html>"##,
11535    );
11536
11537    Html(html).into_response()
11538}
11539
11540fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11541    use std::collections::HashMap;
11542    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
11543        return vec![];
11544    }
11545    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11546    for rec in per_file_records {
11547        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11548            let e = totals.entry(lang.display_name().to_string()).or_default();
11549            e.0 += u64::from(cov.lines_found);
11550            e.1 += u64::from(cov.lines_hit);
11551        }
11552    }
11553    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
11554    let mut pairs: Vec<(String, f64)> = totals
11555        .into_iter()
11556        .filter(|(_, (found, _))| *found > 0)
11557        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11558        .collect();
11559    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11560    pairs
11561        .iter()
11562        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
11563        .collect()
11564}
11565
11566fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
11567    let mut high = 0u64;
11568    let mut mid = 0u64;
11569    let mut low = 0u64;
11570    for rec in per_file_records {
11571        if let Some(cov) = &rec.coverage {
11572            if cov.lines_found == 0 {
11573                continue;
11574            }
11575            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
11576            if pct >= 80.0 {
11577                high += 1;
11578            } else if pct >= 50.0 {
11579                mid += 1;
11580            } else {
11581                low += 1;
11582            }
11583        }
11584    }
11585    (high, mid, low)
11586}
11587
11588fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11589    let mut arr: Vec<serde_json::Value> = per_file_records
11590        .iter()
11591        .filter_map(|rec| {
11592            rec.coverage.as_ref().map(|cov| {
11593                let line_pct = if cov.lines_found > 0 {
11594                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
11595                        / 10.0
11596                } else {
11597                    0.0
11598                };
11599                let fn_pct = if cov.functions_found > 0 {
11600                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
11601                        .round()
11602                        / 10.0
11603                } else {
11604                    -1.0
11605                };
11606                serde_json::json!({
11607                    "rel": rec.relative_path,
11608                    "lang": rec.language.map_or("?", |l| l.display_name()),
11609                    "line_pct": line_pct,
11610                    "fn_pct": fn_pct,
11611                    "lhit": cov.lines_hit,
11612                    "lfound": cov.lines_found,
11613                    "fhit": cov.functions_hit,
11614                    "ffound": cov.functions_found,
11615                })
11616            })
11617        })
11618        .collect();
11619    arr.sort_by(|a, b| {
11620        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
11621        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
11622        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
11623    });
11624    arr
11625}
11626
11627#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11628fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
11629    let mut langs: Vec<&sloc_core::LanguageSummary> = run
11630        .totals_by_language
11631        .iter()
11632        .filter(|l| l.test_count > 0)
11633        .collect();
11634    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11635    let lang_tests: Vec<serde_json::Value> = langs
11636        .iter()
11637        .map(|l| {
11638            let d = if l.code_lines > 0 {
11639                l.test_count as f64 / l.code_lines as f64 * 1000.0
11640            } else {
11641                0.0
11642            };
11643            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11644                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11645                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11646        })
11647        .collect();
11648    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
11649    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11650    let t = &run.summary_totals;
11651    let total_tests = t.test_count;
11652    let density = if t.code_lines > 0 {
11653        total_tests as f64 / t.code_lines as f64 * 1000.0
11654    } else {
11655        0.0
11656    };
11657    let most_tested = langs.first().map_or_else(
11658        || "\u{2014}".to_string(),
11659        |l| l.language.display_name().to_string(),
11660    );
11661    let test_files: u64 = run
11662        .per_file_records
11663        .iter()
11664        .filter(|f| f.raw_line_categories.test_count > 0)
11665        .count() as u64;
11666    let cov_line = if t.coverage_lines_found > 0 {
11667        format!(
11668            "{:.1}",
11669            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
11670        )
11671    } else {
11672        "0".to_string()
11673    };
11674    let cov_fn = if t.coverage_functions_found > 0 {
11675        format!(
11676            "{:.1}",
11677            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
11678        )
11679    } else {
11680        "0".to_string()
11681    };
11682    let cov_branch = if t.coverage_branches_found > 0 {
11683        format!(
11684            "{:.1}",
11685            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
11686        )
11687    } else {
11688        "0".to_string()
11689    };
11690    let has_cov = !cov_arr.is_empty();
11691    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
11692    serde_json::json!({
11693        "totals": {
11694            "test_count": total_tests,
11695            "assertions": t.test_assertion_count,
11696            "suites": t.test_suite_count,
11697            "test_files": test_files,
11698            "total_files": t.files_analyzed,
11699            "density_str": format!("{density:.1}"),
11700            "most_tested": most_tested,
11701            "langs_with_tests": langs.len(),
11702            "cov_line": cov_line,
11703            "cov_fn": cov_fn,
11704            "cov_branch": cov_branch,
11705        },
11706        "lang_tests": lang_tests,
11707        "cov": cov_arr,
11708        "cov_tiers": {"high": high, "mid": mid, "low": low},
11709        "file_cov": file_cov_arr,
11710        "has_coverage": has_cov,
11711        "submodules": {},
11712    })
11713}
11714
11715#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11716fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
11717    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
11718        .language_summaries
11719        .iter()
11720        .filter(|l| l.test_count > 0)
11721        .collect();
11722    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11723    let lang_tests: Vec<serde_json::Value> = langs
11724        .iter()
11725        .map(|l| {
11726            let d = if l.code_lines > 0 {
11727                l.test_count as f64 / l.code_lines as f64 * 1000.0
11728            } else {
11729                0.0
11730            };
11731            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11732                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11733                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11734        })
11735        .collect();
11736    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
11737    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
11738    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
11739    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
11740    let density = if sub.code_lines > 0 {
11741        total_tests as f64 / sub.code_lines as f64 * 1000.0
11742    } else {
11743        0.0
11744    };
11745    let most_tested = langs.first().map_or_else(
11746        || "\u{2014}".to_string(),
11747        |l| l.language.display_name().to_string(),
11748    );
11749    serde_json::json!({
11750        "totals": {
11751            "test_count": total_tests,
11752            "assertions": total_assertions,
11753            "suites": total_suites,
11754            "test_files": test_files_approx,
11755            "total_files": sub.files_analyzed,
11756            "density_str": format!("{density:.1}"),
11757            "most_tested": most_tested,
11758            "langs_with_tests": langs.len(),
11759            "cov_line": "0",
11760            "cov_fn": "0",
11761            "cov_branch": "0",
11762        },
11763        "lang_tests": lang_tests,
11764        "cov": [],
11765        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
11766        "has_coverage": false,
11767    })
11768}
11769
11770fn compute_cov_json_str(run: &AnalysisRun) -> String {
11771    use std::collections::HashMap;
11772    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11773    for rec in &run.per_file_records {
11774        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11775            let e = totals.entry(lang.display_name().to_string()).or_default();
11776            e.0 += u64::from(cov.lines_found);
11777            e.1 += u64::from(cov.lines_hit);
11778        }
11779    }
11780    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
11781    let mut pairs: Vec<(String, f64)> = totals
11782        .into_iter()
11783        .filter(|(_, (found, _))| *found > 0)
11784        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11785        .collect();
11786    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11787    let parts: Vec<String> = pairs
11788        .iter()
11789        .map(|(lang, pct)| {
11790            let name = lang.replace('"', "\\\"");
11791            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
11792        })
11793        .collect();
11794    format!("[{}]", parts.join(","))
11795}
11796
11797fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
11798    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11799    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
11800}
11801
11802fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
11803    let mut entry = build_test_scope_entry(run);
11804    if !run.submodule_summaries.is_empty() {
11805        let subs: serde_json::Map<String, serde_json::Value> = run
11806            .submodule_summaries
11807            .iter()
11808            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
11809            .collect();
11810        entry["submodules"] = serde_json::Value::Object(subs);
11811    }
11812    entry
11813}
11814
11815fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
11816    let name = l.language.display_name().replace('"', "\\\"");
11817    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
11818    let density = if l.code_lines > 0 {
11819        l.test_count as f64 / l.code_lines as f64 * 1000.0
11820    } else {
11821        0.0
11822    };
11823    format!(
11824        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
11825        name = name,
11826        t = l.test_count,
11827        a = l.test_assertion_count,
11828        s = l.test_suite_count,
11829        c = l.code_lines,
11830        d = density,
11831        f = l.files,
11832    )
11833}
11834
11835fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
11836    let Some(r) = run else {
11837        return "[]".to_string();
11838    };
11839    let mut langs: Vec<&sloc_core::LanguageSummary> = r
11840        .totals_by_language
11841        .iter()
11842        .filter(|l| l.test_count > 0)
11843        .collect();
11844    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11845    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
11846    format!("[{}]", parts.join(","))
11847}
11848
11849/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
11850async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
11851    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
11852    scope_map.insert(
11853        "__all__".to_string(),
11854        latest_run.map_or_else(
11855            || {
11856                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
11857                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
11858                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
11859                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
11860                    "has_coverage":false,"submodules":{}})
11861            },
11862            build_test_scope_entry,
11863        ),
11864    );
11865    let all_roots: Vec<String> = {
11866        let reg = state.registry.lock().await;
11867        let mut seen = std::collections::BTreeSet::new();
11868        reg.entries
11869            .iter()
11870            .flat_map(|e| e.input_roots.iter().cloned())
11871            .filter(|r| seen.insert(r.clone()))
11872            .collect()
11873    };
11874    for root in &all_roots {
11875        let json_path = {
11876            let reg = state.registry.lock().await;
11877            reg.entries
11878                .iter()
11879                .find(|e| e.input_roots.iter().any(|r| r == root))
11880                .and_then(|e| e.json_path.clone())
11881        };
11882        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
11883            let json_str = tokio::fs::read_to_string(&p).await.ok();
11884            json_str
11885                .as_deref()
11886                .and_then(|s| serde_json::from_str(s).ok())
11887        } else {
11888            None
11889        };
11890        if let Some(ref run) = run_for_root {
11891            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
11892        }
11893    }
11894    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
11895}
11896
11897// GET /test-metrics
11898#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
11899#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
11900async fn test_metrics_handler(
11901    State(state): State<AppState>,
11902    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11903) -> Response {
11904    auto_scan_watched_dirs(&state).await;
11905    let watched_dirs_list: Vec<String> = {
11906        let wd = state.watched_dirs.lock().await;
11907        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11908    };
11909    let latest_run: Option<AnalysisRun> = {
11910        let json_path = {
11911            let reg = state.registry.lock().await;
11912            reg.entries.first().and_then(|e| e.json_path.clone())
11913        };
11914        if let Some(p) = json_path {
11915            let json_str = tokio::fs::read_to_string(&p).await.ok();
11916            json_str
11917                .as_deref()
11918                .and_then(|s| serde_json::from_str(s).ok())
11919        } else {
11920            None
11921        }
11922    };
11923
11924    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
11925    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
11926
11927    // Build coverage chart JSON (per-language avg line coverage %).
11928    let cov_json: String = latest_run
11929        .as_ref()
11930        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11931        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
11932
11933    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
11934    let _cov_tier_json: String = latest_run
11935        .as_ref()
11936        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11937        .map_or_else(
11938            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
11939            compute_cov_tier_json_str,
11940        );
11941
11942    let total_tests: u64 = latest_run
11943        .as_ref()
11944        .map_or(0, |r| r.summary_totals.test_count);
11945    let total_assertions: u64 = latest_run
11946        .as_ref()
11947        .map_or(0, |r| r.summary_totals.test_assertion_count);
11948    let total_suites: u64 = latest_run
11949        .as_ref()
11950        .map_or(0, |r| r.summary_totals.test_suite_count);
11951    let total_code: u64 = latest_run
11952        .as_ref()
11953        .map_or(0, |r| r.summary_totals.code_lines);
11954    let workspace_density: f64 = if total_code > 0 {
11955        total_tests as f64 / total_code as f64 * 1000.0
11956    } else {
11957        0.0
11958    };
11959    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
11960        r.totals_by_language
11961            .iter()
11962            .filter(|l| l.test_count > 0)
11963            .count()
11964    });
11965    let most_tested: String = latest_run
11966        .as_ref()
11967        .and_then(|r| {
11968            r.totals_by_language
11969                .iter()
11970                .filter(|l| l.test_count > 0)
11971                .max_by_key(|l| l.test_count)
11972        })
11973        .map_or_else(
11974            || "\u{2014}".to_string(),
11975            |l| l.language.display_name().to_string(),
11976        );
11977    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
11978        r.per_file_records
11979            .iter()
11980            .filter(|f| f.raw_line_categories.test_count > 0)
11981            .count() as u64
11982    });
11983    let total_files_analyzed: u64 = latest_run
11984        .as_ref()
11985        .map_or(0, |r| r.summary_totals.files_analyzed);
11986    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
11987
11988    // Aggregated coverage percentages from summary_totals
11989    let cov_line_pct_str: String = latest_run
11990        .as_ref()
11991        .filter(|r| r.summary_totals.coverage_lines_found > 0)
11992        .map_or_else(
11993            || "0".to_string(),
11994            |r| {
11995                format!(
11996                    "{:.1}",
11997                    r.summary_totals.coverage_lines_hit as f64
11998                        / r.summary_totals.coverage_lines_found as f64
11999                        * 100.0
12000                )
12001            },
12002        );
12003    let cov_fn_pct_str: String = latest_run
12004        .as_ref()
12005        .filter(|r| r.summary_totals.coverage_functions_found > 0)
12006        .map_or_else(
12007            || "0".to_string(),
12008            |r| {
12009                format!(
12010                    "{:.1}",
12011                    r.summary_totals.coverage_functions_hit as f64
12012                        / r.summary_totals.coverage_functions_found as f64
12013                        * 100.0
12014                )
12015            },
12016        );
12017    let cov_branch_pct_str: String = latest_run
12018        .as_ref()
12019        .filter(|r| r.summary_totals.coverage_branches_found > 0)
12020        .map_or_else(
12021            || "0".to_string(),
12022            |r| {
12023                format!(
12024                    "{:.1}",
12025                    r.summary_totals.coverage_branches_hit as f64
12026                        / r.summary_totals.coverage_branches_found as f64
12027                        * 100.0
12028                )
12029            },
12030        );
12031
12032    let cov_no_data_notice = if has_coverage {
12033        String::new()
12034    } else {
12035        String::from(
12036            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
12037<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>
12038<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
12039  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
12040  <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>
12041  <span style="color:var(--muted);font-size:12px;">&middot;</span>
12042  <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>
12043  <span style="color:var(--muted);font-size:12px;">&middot;</span>
12044  <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>
12045</div>
12046<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
12047</div>"#,
12048        )
12049    };
12050
12051    let workspace_density_str = format!("{workspace_density:.1}");
12052    let nonce = &csp_nonce;
12053    let version = env!("CARGO_PKG_VERSION");
12054
12055    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
12056    // of interactive controls — folder watching is managed by the host administrator.
12057    let watched_dirs_html: String = if state.server_mode {
12058        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()
12059    } else {
12060        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12061            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12062                .to_string()
12063        } else {
12064            watched_dirs_list
12065                .iter()
12066                .fold(String::new(), |mut s, d| {
12067                    use std::fmt::Write as _;
12068                    let escaped =
12069                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
12070                    write!(
12071                        s,
12072                        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>"#
12073                    ).expect("write to String is infallible");
12074                    s
12075                })
12076        };
12077        format!(
12078            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>"#
12079        )
12080    };
12081
12082    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
12083    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
12084
12085    let html = format!(
12086        r#"<!doctype html>
12087<html lang="en">
12088<head>
12089  <meta charset="utf-8" />
12090  <meta name="viewport" content="width=device-width, initial-scale=1" />
12091  <title>OxideSLOC | Test Metrics</title>
12092  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12093  <style nonce="{nonce}">
12094    :root {{
12095      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12096      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12097      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12098      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12099      --info-bg:#eef3ff; --info-text:#4467d8;
12100    }}
12101    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12102    *{{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;}}
12103    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12104    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12105    .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;}}
12106    @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));}}}}
12107    .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);}}
12108    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12109    .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));}}
12110    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12111    .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;}}
12112    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12113    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12114    @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; }} }}
12115    .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;}}
12116    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12117    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12118    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12119    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12120    .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;}}
12121    .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;}}
12122    .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;}}
12123    .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;}}
12124    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12125    .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);}}
12126    .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;}}
12127    .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;}}
12128    .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;}}
12129    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12130    .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;}}
12131    .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);}}
12132    .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;}}
12133    .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;}}
12134    .tz-select:focus{{border-color:var(--oxide);}}
12135    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12136    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12137    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12138    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12139    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12140    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12141    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12142    .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;}}
12143    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12144    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12145    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12146    .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;}}
12147    .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;}}
12148    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12149    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
12150    .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);}}
12151    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
12152    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
12153    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
12154    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
12155    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
12156    .chart-canvas-wrap{{position:relative;height:280px;}}
12157    .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;}}
12158    .chart-no-data svg{{opacity:0.35;}}
12159    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
12160    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
12161    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
12162    .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;}}
12163    .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;}}
12164    .data-table tr:last-child td{{border-bottom:none;}}
12165    .data-table tbody tr:hover td{{background:var(--surface-2);}}
12166    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
12167    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
12168    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
12169    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
12170    .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;}}
12171    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
12172    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
12173    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
12174    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
12175    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
12176    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
12177    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
12178    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
12179    .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;}}
12180    .chart-select:focus{{border-color:var(--accent);}}
12181    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12182    .trend-canvas-wrap{{position:relative;height:260px;}}
12183    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12184    .site-footer a{{color:var(--muted);}}
12185    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
12186    .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;}}
12187    .btn:hover{{background:var(--surface-2);}}
12188    .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;}}
12189    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12190    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
12191    .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;}}
12192    .scope-sel:focus{{border-color:var(--accent);}}
12193    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
12194    .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;}}
12195    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12196    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12197    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12198    .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;}}
12199    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12200    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12201    .watched-chip-rm:hover{{color:var(--oxide);}}
12202    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12203    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12204    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12205    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12206    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
12207    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
12208    .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;}}
12209    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
12210    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
12211    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
12212    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
12213    .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;}}
12214    .cov-file-search:focus{{border-color:var(--accent);}}
12215    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
12216    .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;}}
12217    body.dark-theme .cov-file-search{{background:var(--surface);}}
12218    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
12219    .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;}}
12220    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12221    .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;}}
12222    .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);}}
12223    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
12224    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
12225    .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;}}
12226    .chart-modal-close:hover{{opacity:.7;}}
12227    body.dark-theme .chart-modal{{background:var(--surface);}}
12228  </style>
12229</head>
12230<body>
12231  <div class="background-watermarks" aria-hidden="true">
12232    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12233    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12234    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12235    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12236    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12237    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12238  </div>
12239  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12240  <div class="top-nav">
12241    <div class="top-nav-inner">
12242      <a class="brand" href="/">
12243        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12244        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
12245      </a>
12246      <div class="nav-right">
12247        <a class="nav-pill" href="/">Home</a>
12248        <div class="nav-dropdown">
12249          <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>
12250          <div class="nav-dropdown-menu">
12251            <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>
12252          </div>
12253        </div>
12254        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12255        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
12256        <div class="nav-dropdown">
12257          <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>
12258          <div class="nav-dropdown-menu">
12259            <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>
12260          </div>
12261        </div>
12262        <div class="server-status-wrap" id="server-status-wrap">
12263          <div class="nav-pill server-online-pill" id="server-status-pill">
12264            <span class="status-dot" id="status-dot"></span>
12265            <span id="server-status-label">Server</span>
12266            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12267          </div>
12268          <div class="server-status-tip">
12269            OxideSLOC is running — accessible on your network.
12270            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12271          </div>
12272        </div>
12273        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12274          <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>
12275        </button>
12276        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12277          <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>
12278          <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>
12279        </button>
12280      </div>
12281    </div>
12282  </div>
12283
12284  <div class="page">
12285    {watched_dirs_html}
12286    <div class="scope-bar">
12287      <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>
12288      <span class="scope-label">Scope</span>
12289      <div class="scope-sel-wrap">
12290        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
12291        <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);">
12292          <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>
12293          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
12294        </div>
12295      </div>
12296    </div>
12297    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12298      <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>
12299      <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>
12300      <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>
12301      <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>
12302    </div>
12303    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12304      <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>
12305      <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>
12306      <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>
12307      <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>
12308    </div>
12309
12310    <div class="panel" id="viz-panel">
12311      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
12312
12313      <div class="chart-box" style="margin-bottom:18px;">
12314        <div class="chart-box-header">
12315          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
12316          <div style="display:flex;gap:8px;align-items:center;">
12317            <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>
12318            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12319          </div>
12320        </div>
12321        <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>
12322        <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
12323        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
12324      </div>
12325
12326      <div class="chart-row">
12327        <div class="chart-box">
12328          <div class="chart-box-header">
12329            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
12330            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12331          </div>
12332          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
12333          <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>
12334        </div>
12335        <div class="chart-box">
12336          <div class="chart-box-header">
12337            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
12338            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12339          </div>
12340          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
12341          <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>
12342        </div>
12343      </div>
12344
12345      <div class="chart-row">
12346        <div class="chart-box">
12347          <div class="chart-box-header">
12348            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
12349            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12350          </div>
12351          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
12352          <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>
12353        </div>
12354        <div class="chart-box" id="suites-chart-box">
12355          <div class="chart-box-header">
12356            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
12357            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
12358          </div>
12359          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
12360          <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>
12361        </div>
12362      </div>
12363
12364      <div class="chart-row">
12365        <div class="chart-box">
12366          <div class="chart-box-title">Test Files Breakdown</div>
12367          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
12368          <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>
12369        </div>
12370        <div class="chart-box">
12371          <div class="chart-box-title">Test Composition</div>
12372          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
12373          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
12374          <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>
12375        </div>
12376      </div>
12377    </div>
12378
12379    <div class="panel">
12380      <h1>Test Metrics</h1>
12381      <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>
12382
12383      <div class="section-header">Language Breakdown</div>
12384      {cov_no_data_notice}
12385      <div style="overflow-x:auto;">
12386        <table class="data-table" id="lang-table">
12387          <thead><tr>
12388            <th>Language</th>
12389            <th class="num">Test Fns</th>
12390            <th class="num">Assertions</th>
12391            <th class="num">Suites</th>
12392            <th class="num">Code Lines</th>
12393            <th class="num">Files</th>
12394            <th class="num">Density / 1K</th>
12395            <th>Relative Density</th>
12396          </tr></thead>
12397          <tbody id="lang-tbody"></tbody>
12398        </table>
12399      </div>
12400    </div>
12401
12402    <div class="panel" id="cov-panel" style="display:none;">
12403      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
12404      <div class="cov-gauge-row" id="cov-gauges">
12405        <div class="cov-gauge-card">
12406          <div class="cov-gauge-label">Line Coverage</div>
12407          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
12408          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
12409          <div class="cov-gauge-sub">Lines hit / instrumented</div>
12410        </div>
12411        <div class="cov-gauge-card">
12412          <div class="cov-gauge-label">Function Coverage</div>
12413          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
12414          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
12415          <div class="cov-gauge-sub">Functions hit / found</div>
12416        </div>
12417        <div class="cov-gauge-card">
12418          <div class="cov-gauge-label">Branch Coverage</div>
12419          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
12420          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
12421          <div class="cov-gauge-sub">Branches hit / found</div>
12422        </div>
12423      </div>
12424      <div class="chart-row">
12425        <div class="chart-box">
12426          <div class="chart-box-title">Line Coverage % by Language</div>
12427          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
12428        </div>
12429        <div class="chart-box">
12430          <div class="chart-box-title">Coverage Tier Distribution</div>
12431          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
12432        </div>
12433      </div>
12434
12435      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
12436      <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>
12437      <div class="cov-file-toolbar">
12438        <div class="cov-filter-tabs" id="cov-filter-tabs">
12439          <button class="cov-tab active" data-tier="all">All</button>
12440          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
12441          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
12442          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
12443          <button class="cov-tab" data-tier="high">High (≥80%)</button>
12444        </div>
12445        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
12446      </div>
12447      <div style="overflow-x:auto;">
12448        <table class="data-table" id="cov-file-table">
12449          <thead><tr>
12450            <th>File</th>
12451            <th>Lang</th>
12452            <th class="num">Line %</th>
12453            <th class="num">Lines Hit / Found</th>
12454            <th class="num">Fn %</th>
12455            <th class="num">Fns Hit / Found</th>
12456          </tr></thead>
12457          <tbody id="cov-file-tbody"></tbody>
12458        </table>
12459      </div>
12460      <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>
12461      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
12462    </div>
12463
12464  </div>
12465
12466  <footer class="site-footer">
12467    local code analysis - metrics, history and reports
12468    &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>
12469    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12470    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12471    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12472    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12473  </footer>
12474
12475  <script nonce="{nonce}">
12476  (function() {{
12477    // Theme
12478    var b = document.body;
12479    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12480    var tgl = document.getElementById('theme-toggle');
12481    if (tgl) tgl.addEventListener('click', function() {{
12482      var d = b.classList.toggle('dark-theme');
12483      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12484    }});
12485
12486    // Watermarks
12487    (function() {{
12488      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12489      if (!wms.length) return;
12490      var placed = [];
12491      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;}}
12492      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];}}
12493      var half=Math.floor(wms.length/2);
12494      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;}});
12495    }})();
12496
12497    // Code particles
12498    (function() {{
12499      var container = document.getElementById('code-particles');
12500      if (!container) return;
12501      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
12502      for (var i = 0; i < 36; i++) {{
12503        (function(idx) {{
12504          var el = document.createElement('span');
12505          el.className = 'code-particle';
12506          el.textContent = snippets[idx % snippets.length];
12507          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12508          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12509          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12510          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';
12511          container.appendChild(el);
12512        }})(i);
12513      }}
12514    }})();
12515
12516    // Settings modal
12517    (function() {{
12518      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'}}];
12519      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);}});}}
12520      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12521      var btn=document.getElementById('settings-btn');if(!btn)return;
12522      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12523      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>';
12524      document.body.appendChild(m);
12525      var g=document.getElementById('scheme-grid');
12526      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);}});
12527      var cl=document.getElementById('settings-close');
12528      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');}});
12529      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12530      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12531    }})();
12532
12533    // Watched folder picker
12534    (function() {{
12535      var btn = document.getElementById('add-watched-btn');
12536      if (!btn) return;
12537      btn.addEventListener('click', function() {{
12538        fetch('/pick-directory?kind=reports')
12539          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12540          .then(function(data) {{
12541            if (!data.cancelled && data.selected_path) {{
12542              var form = document.createElement('form');
12543              form.method = 'POST';
12544              form.action = '/watched-dirs/add';
12545              var ri = document.createElement('input');
12546              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12547              var fi = document.createElement('input');
12548              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12549              form.appendChild(ri); form.appendChild(fi);
12550              document.body.appendChild(form);
12551              form.submit();
12552            }}
12553          }})
12554          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12555      }});
12556    }})();
12557  }})();
12558  </script>
12559
12560  <script src="/static/chart.js" nonce="{nonce}"></script>
12561  <script nonce="{nonce}">
12562  (function() {{
12563    var SCOPE_DATA = {scope_data_json};
12564    var currentRoot = '__all__';
12565    var currentSub  = '';
12566    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
12567    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
12568    var ALL_CHARTS = [];
12569    var currentLangTests = [];
12570    var currentTrendPts = [];
12571
12572    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();}}
12573    function fmtFull(n){{return Number(n).toLocaleString();}}
12574    function isDark(){{return document.body.classList.contains('dark-theme');}}
12575    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
12576    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
12577    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
12578
12579    function makeDlPlugin(fmtFn, anchor) {{
12580      return {{
12581        afterDatasetsDraw: function(chart) {{
12582          var ctx = chart.ctx;
12583          var tc = txtClr();
12584          chart.data.datasets.forEach(function(ds, di) {{
12585            var meta = chart.getDatasetMeta(di);
12586            meta.data.forEach(function(el, idx) {{
12587              var label = fmtFn(ds.data[idx], di, idx);
12588              if (label == null || label === '') return;
12589              ctx.save();
12590              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
12591              ctx.fillStyle = tc;
12592              if (anchor === 'top') {{
12593                ctx.textAlign = 'center';
12594                ctx.textBaseline = 'bottom';
12595                ctx.fillText(String(label), el.x, el.y - 5);
12596              }} else {{
12597                ctx.textAlign = 'left';
12598                ctx.textBaseline = 'middle';
12599                ctx.fillText(String(label), el.x + 5, el.y);
12600              }}
12601              ctx.restore();
12602            }});
12603          }});
12604        }}
12605      }};
12606    }}
12607
12608    function makeTmOverlay(title, subtitle, h) {{
12609      var overlay = document.createElement('div');
12610      overlay.className = 'chart-modal-overlay';
12611      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
12612      var ch = Math.min(h || 560, maxH);
12613      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
12614      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>';
12615      document.body.appendChild(overlay);
12616      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
12617      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
12618      return document.getElementById('tm-modal-canvas');
12619    }}
12620
12621    function getDataset() {{
12622      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
12623      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
12624      return r;
12625    }}
12626    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
12627
12628    function showNoData(id, show) {{
12629      var el = document.getElementById(id);
12630      if (!el) return;
12631      var wrap = el.previousElementSibling;
12632      el.style.display = show ? '' : 'none';
12633      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
12634    }}
12635
12636    function renderTestCharts(D) {{
12637      currentLangTests = D || [];
12638      testsChart = destroyChart(testsChart);
12639      densityChart = destroyChart(densityChart);
12640      if (!D || !D.length) {{
12641        showNoData('no-data-tests', true);
12642        showNoData('no-data-density', true);
12643        return;
12644      }}
12645      showNoData('no-data-tests', false);
12646      showNoData('no-data-density', false);
12647      var top15 = D.slice(0, 15);
12648      var canvas1 = document.getElementById('canvas-tests');
12649      if (canvas1) {{
12650        testsChart = new Chart(canvas1, {{
12651          type: 'bar',
12652          data: {{
12653            labels: top15.map(function(d){{ return d.lang; }}),
12654            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
12655          }},
12656          options: {{
12657            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12658            layout: {{ padding: {{ right: 64 }} }},
12659            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12660            scales: {{
12661              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12662              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12663            }}
12664          }},
12665          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12666        }});
12667        ALL_CHARTS.push(testsChart);
12668      }}
12669      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
12670      var canvas2 = document.getElementById('canvas-density');
12671      if (canvas2) {{
12672        densityChart = new Chart(canvas2, {{
12673          type: 'bar',
12674          data: {{
12675            labels: topD.map(function(d){{ return d.lang; }}),
12676            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 }}]
12677          }},
12678          options: {{
12679            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12680            layout: {{ padding: {{ right: 64 }} }},
12681            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
12682            scales: {{
12683              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
12684              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12685            }}
12686          }},
12687          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
12688        }});
12689        ALL_CHARTS.push(densityChart);
12690      }}
12691    }}
12692
12693    function renderAssertionsChart(D) {{
12694      assertionsChart = destroyChart(assertionsChart);
12695      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
12696      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
12697      var canvas = document.getElementById('canvas-assertions');
12698      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
12699      showNoData('no-data-assertions', false);
12700      assertionsChart = new Chart(canvas, {{
12701        type: 'bar',
12702        data: {{
12703          labels: top15.map(function(d){{ return d.lang; }}),
12704          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
12705        }},
12706        options: {{
12707          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12708          layout: {{ padding: {{ right: 64 }} }},
12709          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12710          scales: {{
12711            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12712            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12713          }}
12714        }},
12715        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12716      }});
12717      ALL_CHARTS.push(assertionsChart);
12718    }}
12719
12720    function renderSuitesChart(D) {{
12721      suitesChart = destroyChart(suitesChart);
12722      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
12723      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
12724      var canvas = document.getElementById('canvas-suites');
12725      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
12726      showNoData('no-data-suites', false);
12727      suitesChart = new Chart(canvas, {{
12728        type: 'bar',
12729        data: {{
12730          labels: top15.map(function(d){{ return d.lang; }}),
12731          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 }}]
12732        }},
12733        options: {{
12734          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12735          layout: {{ padding: {{ right: 64 }} }},
12736          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12737          scales: {{
12738            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12739            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12740          }}
12741        }},
12742        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12743      }});
12744      ALL_CHARTS.push(suitesChart);
12745    }}
12746
12747    function renderFilesChart(totals) {{
12748      filesChart = destroyChart(filesChart);
12749      var canvas = document.getElementById('canvas-files');
12750      if (!canvas) return;
12751      var testF = totals.test_files || 0;
12752      var totalF = totals.total_files || 0;
12753      var nonTest = Math.max(0, totalF - testF);
12754      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
12755      showNoData('no-data-files', false);
12756      var dark = isDark();
12757      filesChart = new Chart(canvas, {{
12758        type: 'doughnut',
12759        data: {{
12760          labels: ['Test Files', 'Non-Test Files'],
12761          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
12762        }},
12763        options: {{
12764          responsive: true, maintainAspectRatio: false, cutout: '62%',
12765          plugins: {{
12766            legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12767            tooltip: {{ callbacks: {{ label: function(ctx) {{
12768              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
12769              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
12770            }} }} }}
12771          }}
12772        }}
12773      }});
12774      ALL_CHARTS.push(filesChart);
12775    }}
12776
12777    function renderCompositionChart(totals) {{
12778      compositionChart = destroyChart(compositionChart);
12779      var canvas = document.getElementById('canvas-composition');
12780      if (!canvas) return;
12781      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
12782      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
12783      showNoData('no-data-composition', false);
12784      compositionChart = new Chart(canvas, {{
12785        type: 'bar',
12786        data: {{
12787          labels: ['Test Functions', 'Assertions', 'Test Suites'],
12788          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
12789        }},
12790        options: {{
12791          responsive: true, maintainAspectRatio: false,
12792          layout: {{ padding: {{ top: 22 }} }},
12793          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
12794          scales: {{
12795            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
12796            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
12797          }}
12798        }},
12799        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
12800      }});
12801      ALL_CHARTS.push(compositionChart);
12802    }}
12803
12804    function renderCovCharts(covD, tiers) {{
12805      covChart = destroyChart(covChart);
12806      tierChart = destroyChart(tierChart);
12807      var covCanvas = document.getElementById('canvas-cov');
12808      if (covCanvas && covD && covD.length) {{
12809        covChart = new Chart(covCanvas, {{
12810          type: 'bar',
12811          data: {{
12812            labels: covD.map(function(d){{ return d.lang; }}),
12813            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 }}]
12814          }},
12815          options: {{
12816            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12817            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
12818            scales: {{
12819              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
12820              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12821            }}
12822          }}
12823        }});
12824        ALL_CHARTS.push(covChart);
12825      }}
12826      var tierCanvas = document.getElementById('canvas-cov-tiers');
12827      if (tierCanvas && tiers) {{
12828        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
12829        tierChart = new Chart(tierCanvas, {{
12830          type: 'doughnut',
12831          data: {{
12832            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
12833            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
12834          }},
12835          options: {{
12836            responsive: true, maintainAspectRatio: false, cutout: '62%',
12837            plugins: {{
12838              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12839              tooltip: {{ callbacks: {{ label: function(ctx) {{
12840                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
12841                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
12842              }} }} }}
12843            }}
12844          }}
12845        }});
12846        ALL_CHARTS.push(tierChart);
12847      }}
12848    }}
12849
12850    function buildLangTable(D) {{
12851      var tbody = document.getElementById('lang-tbody');
12852      if (!tbody) return;
12853      if (!D || !D.length) {{
12854        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>';
12855        return;
12856      }}
12857      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
12858      tbody.innerHTML = D.map(function(d) {{
12859        var barW = Math.round(d.density / maxDensity * 120);
12860        return '<tr>' +
12861          '<td><strong>' + d.lang + '</strong></td>' +
12862          '<td class="num">' + fmt(d.tests) + '</td>' +
12863          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
12864          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
12865          '<td class="num">' + fmt(d.code) + '</td>' +
12866          '<td class="num">' + fmt(d.files) + '</td>' +
12867          '<td class="num">' + d.density.toFixed(2) + '</td>' +
12868          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
12869          '</tr>';
12870      }}).join('');
12871    }}
12872
12873    var covFileData = [];
12874    var covFileTier = 'all';
12875    var covFileSearch = '';
12876
12877    function pctBadge(pct) {{
12878      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
12879      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
12880      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
12881    }}
12882
12883    function buildCovFileTable() {{
12884      var tbody = document.getElementById('cov-file-tbody');
12885      var empty = document.getElementById('cov-file-empty');
12886      var count = document.getElementById('cov-file-count');
12887      if (!tbody) return;
12888      var srch = covFileSearch.toLowerCase();
12889      var filtered = covFileData.filter(function(f) {{
12890        if (covFileTier === 'zero' && f.line_pct > 0) return false;
12891        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
12892        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
12893        if (covFileTier === 'high' && f.line_pct < 80) return false;
12894        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
12895        return true;
12896      }});
12897      if (!filtered.length) {{
12898        tbody.innerHTML = '';
12899        if (empty) empty.style.display = '';
12900        if (count) count.textContent = '';
12901        return;
12902      }}
12903      if (empty) empty.style.display = 'none';
12904      var shown = Math.min(filtered.length, 500);
12905      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
12906      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
12907        var fnCol = f.fn_pct < 0
12908          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
12909          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
12910        return '<tr>' +
12911          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
12912          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
12913          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
12914          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
12915          fnCol +
12916          '</tr>';
12917      }}).join('');
12918    }}
12919
12920    (function() {{
12921      var tabs = document.getElementById('cov-filter-tabs');
12922      if (tabs) {{
12923        tabs.addEventListener('click', function(e) {{
12924          var btn = e.target.closest('.cov-tab');
12925          if (!btn) return;
12926          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
12927          btn.classList.add('active');
12928          covFileTier = btn.getAttribute('data-tier');
12929          buildCovFileTable();
12930        }});
12931      }}
12932      var srch = document.getElementById('cov-file-search');
12933      if (srch) {{
12934        srch.addEventListener('input', function() {{
12935          covFileSearch = this.value;
12936          buildCovFileTable();
12937        }});
12938      }}
12939    }})();
12940
12941    function updateCovGauges(t) {{
12942      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
12943      var el;
12944      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
12945      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
12946      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
12947      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
12948      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
12949      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
12950    }}
12951
12952    function applyScope() {{
12953      var d = getDataset();
12954      var t = d.totals;
12955      var el;
12956      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
12957      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
12958      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
12959      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
12960      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
12961      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
12962      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
12963      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
12964      renderTestCharts(d.lang_tests);
12965      renderAssertionsChart(d.lang_tests);
12966      renderSuitesChart(d.lang_tests);
12967      renderFilesChart(t);
12968      renderCompositionChart(t);
12969      buildLangTable(d.lang_tests);
12970      var covPanel = document.getElementById('cov-panel');
12971      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
12972      if (d.has_coverage) {{
12973        renderCovCharts(d.cov, d.cov_tiers);
12974        updateCovGauges(t);
12975        covFileData = d.file_cov || [];
12976        covFileTier = 'all';
12977        covFileSearch = '';
12978        var tabs = document.getElementById('cov-filter-tabs');
12979        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
12980        var srch = document.getElementById('cov-file-search');
12981        if (srch) srch.value = '';
12982        buildCovFileTable();
12983      }}
12984      loadTrend();
12985    }}
12986
12987    // Populate scope-root-sel from SCOPE_DATA keys
12988    (function() {{
12989      var sel = document.getElementById('scope-root-sel');
12990      if (!sel) return;
12991      Object.keys(SCOPE_DATA).forEach(function(k) {{
12992        if (k === '__all__') return;
12993        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
12994      }});
12995    }})();
12996
12997    document.getElementById('scope-root-sel').addEventListener('change', function() {{
12998      currentRoot = this.value;
12999      currentSub = '';
13000      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
13001      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
13002      var subWrap = document.getElementById('scope-sub-wrap');
13003      var subSel  = document.getElementById('scope-sub-sel');
13004      subSel.innerHTML = '<option value="">Entire project</option>';
13005      if (subNames.length) {{
13006        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
13007        subWrap.style.display = 'flex';
13008      }} else {{
13009        subWrap.style.display = 'none';
13010      }}
13011      applyScope();
13012    }});
13013
13014    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
13015      currentSub = this.value;
13016      applyScope();
13017    }});
13018
13019    function buildTrend(data) {{
13020      var trendCanvas = document.getElementById('canvas-trend');
13021      var trendEmpty  = document.getElementById('trend-empty');
13022      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
13023      pts = pts.slice().reverse();
13024      currentTrendPts = pts;
13025      if (!pts.length) {{
13026        if (trendCanvas) trendCanvas.style.display = 'none';
13027        if (trendEmpty) trendEmpty.style.display = '';
13028        return;
13029      }}
13030      if (trendCanvas) trendCanvas.style.display = '';
13031      if (trendEmpty) trendEmpty.style.display = 'none';
13032      trendChart = destroyChart(trendChart);
13033      if (!trendCanvas) return;
13034      trendChart = new Chart(trendCanvas, {{
13035        type: 'line',
13036        data: {{
13037          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13038          datasets: [{{
13039            label: 'Test Definitions',
13040            data: pts.map(function(d){{ return d.test_count; }}),
13041            borderColor: '#C45C10',
13042            backgroundColor: 'rgba(196,92,16,0.10)',
13043            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13044            pointRadius: 5, fill: true, tension: 0.3
13045          }}]
13046        }},
13047        options: {{
13048          responsive: true, maintainAspectRatio: false,
13049          layout: {{ padding: {{ top: 22 }} }},
13050          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13051          scales: {{
13052            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
13053            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13054          }}
13055        }},
13056        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13057      }});
13058      ALL_CHARTS.push(trendChart);
13059    }}
13060
13061    // ── Full View expand buttons ──────────────────────────────────────────────
13062    (function() {{
13063      var btn = document.getElementById('tests-expand-btn');
13064      if (!btn) return;
13065      btn.addEventListener('click', function() {{
13066        var D = currentLangTests;
13067        if (!D || !D.length) return;
13068        var top15 = D.slice(0, 15);
13069        var h = Math.max(320, top15.length * 36 + 80);
13070        var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
13071        if (!canvas) return;
13072        new Chart(canvas, {{
13073          type: 'bar',
13074          data: {{
13075            labels: top15.map(function(d){{ return d.lang; }}),
13076            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
13077          }},
13078          options: {{
13079            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13080            layout: {{ padding: {{ right: 72 }} }},
13081            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13082            scales: {{
13083              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13084              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13085            }}
13086          }},
13087          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13088        }});
13089      }});
13090    }})();
13091
13092    (function() {{
13093      var btn = document.getElementById('density-expand-btn');
13094      if (!btn) return;
13095      btn.addEventListener('click', function() {{
13096        var D = currentLangTests;
13097        if (!D || !D.length) return;
13098        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
13099        var h = Math.max(320, topD.length * 36 + 80);
13100        var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
13101        if (!canvas) return;
13102        new Chart(canvas, {{
13103          type: 'bar',
13104          data: {{
13105            labels: topD.map(function(d){{ return d.lang; }}),
13106            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 }}]
13107          }},
13108          options: {{
13109            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13110            layout: {{ padding: {{ right: 72 }} }},
13111            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
13112            scales: {{
13113              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
13114              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13115            }}
13116          }},
13117          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
13118        }});
13119      }});
13120    }})();
13121
13122    (function() {{
13123      var btn = document.getElementById('trend-expand-btn');
13124      if (!btn) return;
13125      btn.addEventListener('click', function() {{
13126        var pts = currentTrendPts;
13127        if (!pts || !pts.length) return;
13128        var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
13129        if (!canvas) return;
13130        new Chart(canvas, {{
13131          type: 'line',
13132          data: {{
13133            labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13134            datasets: [{{
13135              label: 'Test Definitions',
13136              data: pts.map(function(d){{ return d.test_count; }}),
13137              borderColor: '#C45C10',
13138              backgroundColor: 'rgba(196,92,16,0.10)',
13139              pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13140              pointRadius: 5, fill: true, tension: 0.3
13141            }}]
13142          }},
13143          options: {{
13144            responsive: true, maintainAspectRatio: false,
13145            layout: {{ padding: {{ top: 22 }} }},
13146            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13147            scales: {{
13148              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
13149              y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13150            }}
13151          }},
13152          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13153        }});
13154      }});
13155    }})();
13156
13157    (function() {{
13158      var btn = document.getElementById('assertions-expand-btn');
13159      if (!btn) return;
13160      btn.addEventListener('click', function() {{
13161        var D = currentLangTests;
13162        if (!D || !D.length) return;
13163        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
13164        if (!top15.length) return;
13165        var h = Math.max(320, top15.length * 36 + 80);
13166        var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
13167        if (!canvas) return;
13168        new Chart(canvas, {{
13169          type: 'bar',
13170          data: {{
13171            labels: top15.map(function(d){{ return d.lang; }}),
13172            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
13173          }},
13174          options: {{
13175            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13176            layout: {{ padding: {{ right: 72 }} }},
13177            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13178            scales: {{
13179              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13180              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13181            }}
13182          }},
13183          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13184        }});
13185      }});
13186    }})();
13187
13188    (function() {{
13189      var btn = document.getElementById('suites-expand-btn');
13190      if (!btn) return;
13191      btn.addEventListener('click', function() {{
13192        var D = currentLangTests;
13193        if (!D || !D.length) return;
13194        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
13195        if (!top15.length) return;
13196        var h = Math.max(320, top15.length * 36 + 80);
13197        var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
13198        if (!canvas) return;
13199        new Chart(canvas, {{
13200          type: 'bar',
13201          data: {{
13202            labels: top15.map(function(d){{ return d.lang; }}),
13203            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 }}]
13204          }},
13205          options: {{
13206            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13207            layout: {{ padding: {{ right: 72 }} }},
13208            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13209            scales: {{
13210              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13211              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13212            }}
13213          }},
13214          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13215        }});
13216      }});
13217    }})();
13218
13219    function loadTrend() {{
13220      var url = '/api/metrics/history?limit=100';
13221      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
13222      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
13223        buildTrend(data);
13224        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
13225        var btn = document.getElementById('multi-compare-trend-btn');
13226        if (btn) {{
13227          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
13228          if (ids.length >= 2) {{
13229            btn.style.display = '';
13230            btn.onclick = function() {{
13231              // Reverse so oldest first (API returns newest first).
13232              var sorted = ids.slice().reverse();
13233              if (sorted.length === 2) {{
13234                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
13235              }} else {{
13236                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
13237              }}
13238            }};
13239          }} else {{
13240            btn.style.display = 'none';
13241          }}
13242        }}
13243      }}).catch(function(){{
13244        var trendEmpty = document.getElementById('trend-empty');
13245        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
13246      }});
13247    }}
13248
13249    // Re-render charts on theme toggle
13250    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
13251      setTimeout(function() {{
13252        ALL_CHARTS.forEach(function(c) {{
13253          if (c && c.options && c.options.scales) {{
13254            Object.values(c.options.scales).forEach(function(ax) {{
13255              if (ax.grid) ax.grid.color = clr();
13256              if (ax.ticks) ax.ticks.color = txtClr();
13257            }});
13258            c.update();
13259          }}
13260        }});
13261      }}, 80);
13262    }});
13263
13264    applyScope();
13265  }})();
13266  </script>
13267  <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>
13268</body>
13269</html>"#,
13270    );
13271    Html(html).into_response()
13272}
13273
13274// ── Embeddable widget ─────────────────────────────────────────────────────────
13275// Protected. Returns a self-contained HTML page suitable for iframing inside
13276// Jenkins build summaries, Confluence iframe macros, or Jira panels.
13277//
13278// GET /embed/summary?run_id=<uuid>&theme=dark
13279
13280#[derive(Deserialize)]
13281struct EmbedQuery {
13282    run_id: Option<String>,
13283    theme: Option<String>,
13284}
13285
13286async fn embed_handler(
13287    State(state): State<AppState>,
13288    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13289    Query(query): Query<EmbedQuery>,
13290) -> Response {
13291    let entry = {
13292        let reg = state.registry.lock().await;
13293        query.run_id.as_ref().map_or_else(
13294            || reg.entries.first().cloned(),
13295            |id| reg.find_by_run_id(id).cloned(),
13296        )
13297    };
13298
13299    let Some(entry) = entry else {
13300        return Html(
13301            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
13302                .to_string(),
13303        )
13304        .into_response();
13305    };
13306
13307    let dark = query.theme.as_deref() == Some("dark");
13308    let languages: Vec<(String, u64, u64)> = entry
13309        .json_path
13310        .as_ref()
13311        .and_then(|p| read_json(p).ok())
13312        .map(|run| {
13313            run.totals_by_language
13314                .iter()
13315                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
13316                .collect()
13317        })
13318        .unwrap_or_default();
13319
13320    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
13321}
13322
13323fn render_embed_widget(
13324    entry: &RegistryEntry,
13325    languages: &[(String, u64, u64)],
13326    dark: bool,
13327    csp_nonce: &str,
13328) -> String {
13329    let s = &entry.summary;
13330    let total = s.code_lines + s.comment_lines + s.blank_lines;
13331    let code_pct = s
13332        .code_lines
13333        .checked_mul(100)
13334        .and_then(|n| n.checked_div(total))
13335        .unwrap_or(0);
13336
13337    let (bg, fg, surface, muted, border) = if dark {
13338        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
13339    } else {
13340        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
13341    };
13342
13343    let mut lang_rows = String::new();
13344    for (name, files, code) in languages {
13345        write!(
13346            lang_rows,
13347            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
13348            escape_html(name),
13349            format_number(*files),
13350            format_number(*code),
13351        )
13352        .ok();
13353    }
13354
13355    let lang_table = if lang_rows.is_empty() {
13356        String::new()
13357    } else {
13358        format!(
13359            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
13360        )
13361    };
13362
13363    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
13364    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
13365    let project_esc = escape_html(&entry.project_label);
13366    let code_lines = format_number(s.code_lines);
13367    let comment_lines = format_number(s.comment_lines);
13368    let files = format_number(s.files_analyzed);
13369    let code_raw = s.code_lines;
13370    let comment_raw = s.comment_lines;
13371    let blank_raw = s.blank_lines;
13372
13373    format!(
13374        r#"<!doctype html>
13375<html lang="en">
13376<head>
13377  <meta charset="utf-8">
13378  <meta name="viewport" content="width=device-width,initial-scale=1">
13379  <title>OxideSLOC &mdash; {project_esc}</title>
13380  <script src="/static/chart.js"></script>
13381  <style nonce="{csp_nonce}">
13382    *{{box-sizing:border-box;margin:0;padding:0}}
13383    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
13384    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
13385    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
13386    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
13387    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
13388    .card .v{{font-size:18px;font-weight:700}}
13389    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
13390    .row{{display:flex;gap:12px;align-items:flex-start}}
13391    .pie{{width:120px;height:120px;flex-shrink:0}}
13392    .lt{{border-collapse:collapse;width:100%;flex:1}}
13393    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
13394    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
13395    .n{{text-align:right}}
13396    .footer{{margin-top:10px;color:{muted};font-size:10px}}
13397  </style>
13398</head>
13399<body>
13400  <h2>{project_esc}</h2>
13401  <div class="sub">{timestamp} &middot; run {run_short}</div>
13402  <div class="cards">
13403    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
13404    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
13405    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
13406    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
13407  </div>
13408  <div class="row">
13409    <canvas class="pie" id="c"></canvas>
13410    {lang_table}
13411  </div>
13412  <div class="footer">oxide-sloc</div>
13413  <script nonce="{csp_nonce}">
13414    new Chart(document.getElementById('c'),{{
13415      type:'doughnut',
13416      data:{{
13417        labels:['Code','Comments','Blank'],
13418        datasets:[{{
13419          data:[{code_raw},{comment_raw},{blank_raw}],
13420          backgroundColor:['#4a78ee','#b35428','#aaa'],
13421          borderWidth:0
13422        }}]
13423      }},
13424      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
13425    }});
13426  </script>
13427</body>
13428</html>"#
13429    )
13430}
13431
13432/// Returns a process-wide mutex unique to `dir`, so that two requests writing
13433/// artifacts into the *same* output directory (e.g. re-ingesting an identical
13434/// `run_id`) serialize instead of corrupting each other's files. Directories that
13435/// differ never contend, so legitimate parallel analyses keep their throughput.
13436fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
13437    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
13438        OnceLock::new();
13439    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
13440    let mut guard = map
13441        .lock()
13442        .unwrap_or_else(std::sync::PoisonError::into_inner);
13443    guard
13444        .entry(dir.to_path_buf())
13445        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
13446        .clone()
13447}
13448
13449#[allow(clippy::too_many_lines)]
13450fn persist_run_artifacts(
13451    run: &sloc_core::AnalysisRun,
13452    report_html: &str,
13453    run_dir: &Path,
13454    report_title: &str,
13455    file_stem: &str,
13456    result_context: RunResultContext,
13457) -> Result<(RunArtifacts, PendingPdf)> {
13458    // Serialize concurrent writers targeting this same output directory so their
13459    // file writes cannot interleave and corrupt one another.
13460    let dir_lock = output_dir_lock(run_dir);
13461    let _dir_guard = dir_lock
13462        .lock()
13463        .unwrap_or_else(std::sync::PoisonError::into_inner);
13464
13465    // Root dir + organised subdirectories.
13466    let html_dir = run_dir.join("html");
13467    let pdf_dir = run_dir.join("pdf");
13468    let excel_dir = run_dir.join("excel");
13469    let json_dir = run_dir.join("json");
13470    let submodules_dir = run_dir.join("submodules");
13471    for dir in &[
13472        run_dir,
13473        &html_dir,
13474        &pdf_dir,
13475        &excel_dir,
13476        &json_dir,
13477        &submodules_dir,
13478    ] {
13479        fs::create_dir_all(dir)
13480            .with_context(|| format!("failed to create directory {}", dir.display()))?;
13481    }
13482
13483    // HTML report in html/.
13484    let html_path = {
13485        let path = html_dir.join(format!("report_{file_stem}.html"));
13486        fs::write(&path, report_html)
13487            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
13488        Some(path)
13489    };
13490
13491    // JSON result in json/.
13492    let json_path = {
13493        let path = json_dir.join(format!("result_{file_stem}.json"));
13494        let json = serde_json::to_string_pretty(run)
13495            .context("failed to serialize analysis run to JSON")?;
13496        fs::write(&path, json)
13497            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
13498        Some(path)
13499    };
13500
13501    // PDF in pdf/.
13502    let (pdf_path, pending_pdf) = {
13503        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
13504        match write_pdf_from_run(run, &pdf_dest) {
13505            Ok(()) => {
13506                eprintln!(
13507                    "[oxide-sloc][pdf] native PDF written to {}",
13508                    pdf_dest.display()
13509                );
13510                (Some(pdf_dest), None)
13511            }
13512            Err(native_err) => {
13513                eprintln!(
13514                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
13515                );
13516                let source_html_path = html_path
13517                    .as_ref()
13518                    .expect("html_path always Some here")
13519                    .clone();
13520                let pending = Some((source_html_path, pdf_dest.clone(), false));
13521                (Some(pdf_dest), pending)
13522            }
13523        }
13524    };
13525
13526    // CSV and XLSX in excel/.
13527    let csv_path = {
13528        let path = excel_dir.join(format!("report_{file_stem}.csv"));
13529        if let Err(e) = sloc_report::write_csv(run, &path) {
13530            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
13531            None
13532        } else {
13533            Some(path)
13534        }
13535    };
13536
13537    let xlsx_path = {
13538        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
13539        if let Err(e) = sloc_report::write_xlsx(run, &path) {
13540            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
13541            None
13542        } else {
13543            Some(path)
13544        }
13545    };
13546
13547    // Scan config in json/.
13548    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
13549
13550    // Eagerly generate sub-reports before index.html so relative links work.
13551    if run.effective_configuration.discovery.submodule_breakdown {
13552        let run_id = &run.tool.run_id;
13553        for s in &run.submodule_summaries {
13554            build_submodule_row(s, run, run_id, run_dir);
13555        }
13556    }
13557
13558    // index.html at root — offline static export of the result-page dashboard.
13559    generate_offline_index(
13560        run,
13561        run_dir,
13562        file_stem,
13563        html_path.as_deref(),
13564        pdf_path.as_deref(),
13565        json_path.as_deref(),
13566        scan_config_path.as_deref(),
13567        &result_context,
13568    );
13569
13570    Ok((
13571        RunArtifacts {
13572            output_dir: run_dir.to_path_buf(),
13573            html_path,
13574            pdf_path,
13575            json_path,
13576            csv_path,
13577            xlsx_path,
13578            scan_config_path,
13579            report_title: report_title.to_string(),
13580            result_context,
13581        },
13582        pending_pdf,
13583    ))
13584}
13585
13586/// Render a static offline result-page dashboard and write it as `index.html` at
13587/// the root of the run output directory so business users can open it from disk.
13588#[allow(clippy::too_many_arguments)]
13589#[allow(clippy::too_many_lines)]
13590#[allow(clippy::similar_names)]
13591fn generate_offline_index(
13592    run: &sloc_core::AnalysisRun,
13593    run_dir: &Path,
13594    file_stem: &str,
13595    html_path: Option<&Path>,
13596    pdf_path: Option<&Path>,
13597    json_path: Option<&Path>,
13598    scan_config_path: Option<&Path>,
13599    result_context: &RunResultContext,
13600) {
13601    let prev_entry = &result_context.prev_entry;
13602    let prev_scan_count = result_context.prev_scan_count;
13603    let project_path = &result_context.project_path;
13604
13605    let scan_delta = prev_entry.as_ref().and_then(|prev| {
13606        prev.json_path
13607            .as_ref()
13608            .and_then(|p| read_json(p).ok())
13609            .map(|prev_run| compute_delta(&prev_run, run))
13610    });
13611
13612    let files_analyzed = run.per_file_records.len() as u64;
13613    let files_skipped = run.skipped_file_records.len() as u64;
13614    let physical_lines = run
13615        .totals_by_language
13616        .iter()
13617        .map(|r| r.total_physical_lines)
13618        .sum::<u64>();
13619    let code_lines = run
13620        .totals_by_language
13621        .iter()
13622        .map(|r| r.code_lines)
13623        .sum::<u64>();
13624    let comment_lines = run
13625        .totals_by_language
13626        .iter()
13627        .map(|r| r.comment_lines)
13628        .sum::<u64>();
13629    let blank_lines = run
13630        .totals_by_language
13631        .iter()
13632        .map(|r| r.blank_lines)
13633        .sum::<u64>();
13634    let mixed_lines = run
13635        .totals_by_language
13636        .iter()
13637        .map(|r| r.mixed_lines_separate)
13638        .sum::<u64>();
13639    let functions = run
13640        .totals_by_language
13641        .iter()
13642        .map(|r| r.functions)
13643        .sum::<u64>();
13644    let classes = run
13645        .totals_by_language
13646        .iter()
13647        .map(|r| r.classes)
13648        .sum::<u64>();
13649    let variables = run
13650        .totals_by_language
13651        .iter()
13652        .map(|r| r.variables)
13653        .sum::<u64>();
13654    let imports = run
13655        .totals_by_language
13656        .iter()
13657        .map(|r| r.imports)
13658        .sum::<u64>();
13659
13660    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
13661    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
13662    let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
13663    let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
13664    let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
13665    let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
13666    let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
13667    let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
13668
13669    let (delta_fa_str, delta_fa_class) =
13670        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
13671    let (delta_fs_str, delta_fs_class) =
13672        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
13673    let (delta_pl_str, delta_pl_class) =
13674        summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
13675    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
13676    let (delta_cml_str, delta_cml_class) =
13677        summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
13678    let (delta_bl_str, delta_bl_class) =
13679        summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
13680
13681    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
13682    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
13683    let (delta_lines_net_str, delta_lines_net_class) =
13684        match (delta_lines_added, delta_lines_removed) {
13685            (Some(a), Some(r)) => {
13686                let net = a - r;
13687                (fmt_delta(net), delta_class(net).to_string())
13688            }
13689            _ => ("\u{2014}".to_string(), "na".to_string()),
13690        };
13691
13692    let git_commit_url = run
13693        .git_remote_url
13694        .as_deref()
13695        .zip(run.git_commit_long.as_deref())
13696        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
13697    let git_branch_url = run
13698        .git_remote_url
13699        .as_deref()
13700        .zip(run.git_branch.as_deref())
13701        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
13702    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
13703        format!(
13704            "{} / {}",
13705            run.environment.initiator_username, run.environment.initiator_hostname
13706        )
13707    });
13708
13709    // Convert absolute path to relative from run_dir (for file:// navigation).
13710    let make_rel = |p: Option<&Path>| -> Option<String> {
13711        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
13712            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
13713    };
13714
13715    let run_id = &run.tool.run_id;
13716
13717    // Submodule rows with relative paths into submodules/.
13718    let submodule_rows: Vec<SubmoduleRow> = run
13719        .submodule_summaries
13720        .iter()
13721        .map(|s| {
13722            let safe = sanitize_project_label(&s.name);
13723            let key = format!("sub_{safe}");
13724            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
13725            SubmoduleRow {
13726                name: s.name.clone(),
13727                relative_path: s.relative_path.clone(),
13728                files_analyzed: s.files_analyzed,
13729                code_lines: s.code_lines,
13730                comment_lines: s.comment_lines,
13731                blank_lines: s.blank_lines,
13732                total_physical_lines: s.total_physical_lines,
13733                html_url: if sub_path.exists() {
13734                    Some(format!("submodules/{key}.html"))
13735                } else {
13736                    None
13737                },
13738            }
13739        })
13740        .collect();
13741
13742    let lang_chart_json = {
13743        let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
13744        langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
13745        let entries: Vec<String> = langs
13746            .into_iter()
13747            .take(12)
13748            .map(|l| {
13749                let name = l.language.display_name()
13750                    .replace('\\', "\\\\")
13751                    .replace('"', "\\\"");
13752                format!(
13753                    r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
13754                    name, l.code_lines, l.comment_lines, l.blank_lines,
13755                    l.total_physical_lines, l.functions, l.classes,
13756                    l.variables, l.imports, l.files
13757                )
13758            })
13759            .collect();
13760        format!("[{}]", entries.join(","))
13761    };
13762
13763    let scan_config_rel =
13764        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
13765
13766    let template = ResultTemplate {
13767        version: env!("CARGO_PKG_VERSION"),
13768        report_title: run.effective_configuration.reporting.report_title.clone(),
13769        project_path: project_path.clone(),
13770        output_dir: display_path(run_dir),
13771        run_id: run_id.clone(),
13772        run_id_short: run_id
13773            .split('-')
13774            .next_back()
13775            .unwrap_or(run_id)
13776            .chars()
13777            .take(7)
13778            .collect(),
13779        files_analyzed,
13780        files_skipped,
13781        physical_lines,
13782        code_lines,
13783        comment_lines,
13784        blank_lines,
13785        mixed_lines,
13786        functions,
13787        classes,
13788        variables,
13789        imports,
13790        html_url: make_rel(html_path),
13791        pdf_url: make_rel(pdf_path),
13792        json_url: make_rel(json_path),
13793        html_download_url: make_rel(html_path),
13794        pdf_download_url: make_rel(pdf_path),
13795        json_download_url: make_rel(json_path),
13796        html_path: html_path.map(display_path),
13797        json_path: json_path.map(display_path),
13798        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
13799        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
13800        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
13801        prev_fa_str,
13802        prev_fs_str,
13803        prev_pl_str,
13804        prev_cl_str,
13805        prev_cml_str,
13806        prev_bl_str,
13807        delta_fa_str,
13808        delta_fa_class: delta_fa_class.to_string(),
13809        delta_fs_str,
13810        delta_fs_class: delta_fs_class.to_string(),
13811        delta_pl_str,
13812        delta_pl_class: delta_pl_class.to_string(),
13813        delta_cl_str,
13814        delta_cl_class: delta_cl_class.to_string(),
13815        delta_cml_str,
13816        delta_cml_class: delta_cml_class.to_string(),
13817        delta_bl_str,
13818        delta_bl_class: delta_bl_class.to_string(),
13819        delta_lines_added,
13820        delta_lines_removed,
13821        delta_lines_net_str,
13822        delta_lines_net_class,
13823        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
13824        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
13825        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
13826        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
13827        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
13828            d.file_deltas
13829                .iter()
13830                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
13831                .map(|f| {
13832                    #[allow(clippy::cast_sign_loss)]
13833                    let n = f.current_code as u64;
13834                    n
13835                })
13836                .sum()
13837        }),
13838        git_branch: run.git_branch.clone(),
13839        git_branch_url,
13840        git_commit: run.git_commit_short.clone(),
13841        git_commit_long: run.git_commit_long.clone(),
13842        git_author: run.git_commit_author.clone(),
13843        git_commit_url,
13844        scan_performed_by,
13845        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
13846        os_display: format!(
13847            "{} / {}",
13848            run.environment.operating_system, run.environment.architecture
13849        ),
13850        test_count: run.summary_totals.test_count,
13851        current_scan_number: prev_scan_count + 1,
13852        prev_scan_count,
13853        submodule_rows,
13854        pdf_generating: false,
13855        scan_config_url: scan_config_rel,
13856        lang_chart_json,
13857        scatter_chart_json: String::new(),
13858        semantic_chart_json: String::new(),
13859        submodule_chart_json: String::new(),
13860        has_submodule_data: !run.submodule_summaries.is_empty(),
13861        has_semantic_data: run
13862            .totals_by_language
13863            .iter()
13864            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
13865        csp_nonce: String::new(),
13866        confluence_configured: false,
13867        server_mode: false,
13868        report_header_footer: run
13869            .effective_configuration
13870            .reporting
13871            .report_header_footer
13872            .clone(),
13873        is_offline: true,
13874        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
13875        lsloc: run.summary_totals.lsloc,
13876        uloc: run.uloc,
13877        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
13878        duplicate_group_count: run.duplicate_groups.len(),
13879        has_cocomo: run.cocomo.is_some(),
13880        cocomo_effort_str: run
13881            .cocomo
13882            .as_ref()
13883            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
13884        cocomo_duration_str: run
13885            .cocomo
13886            .as_ref()
13887            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
13888        cocomo_staff_str: run
13889            .cocomo
13890            .as_ref()
13891            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
13892        cocomo_ksloc_str: run
13893            .cocomo
13894            .as_ref()
13895            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
13896        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
13897            || "Organic".to_string(),
13898            |c| {
13899                use sloc_core::CocomoMode;
13900                match c.mode {
13901                    CocomoMode::Organic => "Organic",
13902                    CocomoMode::SemiDetached => "Semi-detached",
13903                    CocomoMode::Embedded => "Embedded",
13904                }
13905                .to_string()
13906            },
13907        ),
13908        cocomo_mode_tooltip: run.cocomo.as_ref().map_or(String::new(), |c| {
13909            use sloc_core::CocomoMode;
13910            match c.mode {
13911                CocomoMode::Organic => {
13912                    "Organic: A small team working on a well-understood \
13913                    project in a familiar environment with minimal external constraints. \
13914                    Suited for internal tools, utilities, and projects with stable requirements. \
13915                    Effort = 2.4 \u{00D7} KSLOC^1.05."
13916                }
13917                CocomoMode::SemiDetached => {
13918                    "Semi-detached: A mixed team with varying experience \
13919                    tackling a project with moderate novelty and some rigid constraints. \
13920                    Typical for compilers, transaction systems, and batch processors. \
13921                    Effort = 3.0 \u{00D7} KSLOC^1.12."
13922                }
13923                CocomoMode::Embedded => {
13924                    "Embedded: Tight hardware, software, or operational \
13925                    constraints requiring significant innovation and deep integration work. \
13926                    Typical for real-time control systems and safety-critical software. \
13927                    Effort = 3.6 \u{00D7} KSLOC^1.20."
13928                }
13929            }
13930            .to_string()
13931        }),
13932        complexity_alert: 0,
13933    };
13934
13935    if let Ok(html) = template.render() {
13936        let index_path = run_dir.join("index.html");
13937        if let Err(e) = fs::write(&index_path, html) {
13938            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
13939        }
13940    }
13941}
13942
13943/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
13944/// then root (old flat layout), for backwards compatibility.
13945fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
13946    // New layout: json/scan-config_*.json
13947    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
13948        return Some(found);
13949    }
13950    // Old flat layout: scan-config.json or scan-config_*.json at root
13951    find_scan_config_in_dir_flat(dir)
13952}
13953
13954fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
13955    let exact = dir.join("scan-config.json");
13956    if exact.exists() {
13957        return Some(exact);
13958    }
13959    fs::read_dir(dir).ok().and_then(|entries| {
13960        entries
13961            .filter_map(std::result::Result::ok)
13962            .find(|e| {
13963                let name = e.file_name();
13964                let name = name.to_string_lossy();
13965                name.starts_with("scan-config") && name.ends_with(".json")
13966            })
13967            .map(|e| e.path())
13968    })
13969}
13970
13971// ── Config export / import ────────────────────────────────────────────────────
13972
13973/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
13974/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
13975#[derive(Deserialize)]
13976struct ExportPdfRequest {
13977    html: String,
13978    #[serde(default)]
13979    filename: Option<String>,
13980}
13981
13982async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
13983    let html_content = body.html;
13984    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
13985    if html_content.is_empty() {
13986        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
13987    }
13988    // Write HTML to a temp file, run headless Chrome PDF export, read result.
13989    let tmp_dir = std::env::temp_dir();
13990    let html_path = tmp_dir.join(format!(
13991        "sloc-export-{}.html",
13992        uuid::Uuid::new_v4().simple()
13993    ));
13994    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
13995    if let Err(e) = std::fs::write(&html_path, &html_content) {
13996        return (
13997            StatusCode::INTERNAL_SERVER_ERROR,
13998            format!("Failed to write temp HTML: {e}"),
13999        )
14000            .into_response();
14001    }
14002    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
14003    let _ = std::fs::remove_file(&html_path);
14004    if let Err(e) = pdf_result {
14005        let _ = std::fs::remove_file(&pdf_path);
14006        return (
14007            StatusCode::INTERNAL_SERVER_ERROR,
14008            format!("PDF generation failed: {e}"),
14009        )
14010            .into_response();
14011    }
14012    let pdf_bytes = match std::fs::read(&pdf_path) {
14013        Ok(b) => b,
14014        Err(e) => {
14015            let _ = std::fs::remove_file(&pdf_path);
14016            return (
14017                StatusCode::INTERNAL_SERVER_ERROR,
14018                format!("Failed to read PDF: {e}"),
14019            )
14020                .into_response();
14021        }
14022    };
14023    let _ = std::fs::remove_file(&pdf_path);
14024    let safe_name: String = filename
14025        .chars()
14026        .map(|c| {
14027            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
14028                c
14029            } else {
14030                '_'
14031            }
14032        })
14033        .collect();
14034    let disposition = format!("attachment; filename=\"{safe_name}\"");
14035    (
14036        [
14037            (header::CONTENT_TYPE, "application/pdf".to_string()),
14038            (header::CONTENT_DISPOSITION, disposition),
14039        ],
14040        pdf_bytes,
14041    )
14042        .into_response()
14043}
14044
14045async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
14046    let toml_str = match toml::to_string_pretty(&state.base_config) {
14047        Ok(s) => s,
14048        Err(e) => {
14049            return (
14050                StatusCode::INTERNAL_SERVER_ERROR,
14051                format!("serialization error: {e}"),
14052            )
14053                .into_response();
14054        }
14055    };
14056    (
14057        [
14058            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
14059            (
14060                header::CONTENT_DISPOSITION,
14061                "attachment; filename=\".oxide-sloc.toml\"",
14062            ),
14063        ],
14064        toml_str,
14065    )
14066        .into_response()
14067}
14068
14069#[derive(Serialize)]
14070struct OkResponse {
14071    ok: bool,
14072}
14073
14074#[derive(Serialize)]
14075struct SaveProfileResponse {
14076    ok: bool,
14077    id: String,
14078}
14079
14080#[derive(Serialize)]
14081struct ProfileListResponse {
14082    profiles: Vec<ScanProfile>,
14083}
14084
14085#[derive(Serialize)]
14086struct ImportConfigResponse {
14087    ok: bool,
14088    config: sloc_config::AppConfig,
14089}
14090
14091#[derive(Deserialize)]
14092struct ImportConfigBody {
14093    toml: String,
14094}
14095
14096async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
14097    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
14098        Ok(config) => {
14099            if let Err(e) = config.validate() {
14100                return error::unprocessable_entity(&e.to_string());
14101            }
14102            Json(ImportConfigResponse { ok: true, config }).into_response()
14103        }
14104        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
14105    }
14106}
14107
14108// ── Scan profiles API ─────────────────────────────────────────────────────────
14109
14110async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
14111    let store = state.scan_profiles.lock().await;
14112    Json(ProfileListResponse {
14113        profiles: store.profiles.clone(),
14114    })
14115}
14116
14117#[derive(Deserialize)]
14118struct SaveScanProfileBody {
14119    name: String,
14120    params: serde_json::Value,
14121}
14122
14123async fn api_save_scan_profile(
14124    State(state): State<AppState>,
14125    Json(body): Json<SaveScanProfileBody>,
14126) -> impl IntoResponse {
14127    if body.name.trim().is_empty() {
14128        return error::bad_request("name must not be empty");
14129    }
14130
14131    let id = uuid::Uuid::new_v4().to_string();
14132    let profile = ScanProfile {
14133        id: id.clone(),
14134        name: body.name.trim().to_string(),
14135        created_at: chrono::Utc::now().to_rfc3339(),
14136        params: body.params,
14137    };
14138
14139    let mut store = state.scan_profiles.lock().await;
14140    store.profiles.push(profile);
14141    if let Err(e) = store.save(&state.scan_profiles_path) {
14142        tracing::warn!("failed to persist scan profiles: {e}");
14143    }
14144    drop(store);
14145
14146    (
14147        StatusCode::CREATED,
14148        Json(SaveProfileResponse { ok: true, id }),
14149    )
14150        .into_response()
14151}
14152
14153async fn api_delete_scan_profile(
14154    State(state): State<AppState>,
14155    AxumPath(id): AxumPath<String>,
14156) -> impl IntoResponse {
14157    let mut store = state.scan_profiles.lock().await;
14158    let before = store.profiles.len();
14159    store.profiles.retain(|p| p.id != id);
14160    if store.profiles.len() == before {
14161        drop(store);
14162        return error::not_found("profile not found");
14163    }
14164    if let Err(e) = store.save(&state.scan_profiles_path) {
14165        tracing::warn!("failed to persist scan profiles: {e}");
14166    }
14167    drop(store);
14168    Json(OkResponse { ok: true }).into_response()
14169}
14170
14171fn resolve_output_root(raw: Option<&str>) -> PathBuf {
14172    let value = raw.unwrap_or("out/web").trim();
14173    let path = if value.is_empty() {
14174        PathBuf::from("out/web")
14175    } else {
14176        PathBuf::from(value)
14177    };
14178
14179    if path.is_absolute() {
14180        path
14181    } else {
14182        workspace_root().join(path)
14183    }
14184}
14185
14186/// Derive the directory that holds remote-repo clones from the output root.
14187fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
14188    std::env::var("SLOC_GIT_CLONES_DIR")
14189        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
14190}
14191
14192/// Build a deterministic filesystem path for a cloned remote repository.
14193/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
14194pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
14195    let safe: String = repo_url
14196        .chars()
14197        .map(|c| {
14198            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
14199                c
14200            } else {
14201                '_'
14202            }
14203        })
14204        .take(80)
14205        .collect();
14206    clones_dir.join(safe)
14207}
14208
14209/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
14210/// Runs synchronously — call from `tokio::task::spawn_blocking`.
14211pub(crate) fn scan_path_to_artifacts(
14212    scan_path: &Path,
14213    base_config: &AppConfig,
14214    label: &str,
14215) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
14216    let mut config = base_config.clone();
14217    config.discovery.root_paths = vec![scan_path.to_path_buf()];
14218    label.clone_into(&mut config.reporting.report_title);
14219    let run = analyze(&config, "git", None, None)?;
14220    let html = render_html(&run)?;
14221    let run_id = run.tool.run_id.clone();
14222    let project_label = sanitize_project_label(label);
14223    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
14224    let file_stem = {
14225        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
14226        if commit.is_empty() {
14227            project_label
14228        } else {
14229            format!("{project_label}_{commit}")
14230        }
14231    };
14232    let (artifacts, _pending_pdf) = persist_run_artifacts(
14233        &run,
14234        &html,
14235        &output_dir,
14236        label,
14237        &file_stem,
14238        RunResultContext::default(),
14239    )?;
14240    Ok((run_id, artifacts, run))
14241}
14242
14243/// Re-spawn background poll tasks for any polling schedules saved to disk.
14244async fn restart_poll_schedules(state: &AppState) {
14245    let store = state.schedules.lock().await;
14246    let poll_schedules: Vec<_> = store
14247        .schedules
14248        .iter()
14249        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
14250        .cloned()
14251        .collect();
14252    drop(store);
14253    for schedule in poll_schedules {
14254        let interval = schedule.interval_secs.unwrap_or(300);
14255        let st = state.clone();
14256        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
14257    }
14258}
14259
14260fn split_patterns(raw: Option<&str>) -> Vec<String> {
14261    raw.unwrap_or("")
14262        .lines()
14263        .flat_map(|line| line.split(','))
14264        .map(str::trim)
14265        .filter(|part| !part.is_empty())
14266        .map(ToOwned::to_owned)
14267        .collect()
14268}
14269
14270#[must_use]
14271pub fn build_sub_run(
14272    parent: &AnalysisRun,
14273    sub: &sloc_core::SubmoduleSummary,
14274    parent_path: &str,
14275) -> AnalysisRun {
14276    let sub_files: Vec<_> = parent
14277        .per_file_records
14278        .iter()
14279        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
14280        .cloned()
14281        .collect();
14282    let mut config = parent.effective_configuration.clone();
14283    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
14284
14285    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
14286    let mut functions = 0u64;
14287    let mut classes = 0u64;
14288    let mut variables = 0u64;
14289    let mut imports = 0u64;
14290    let mut test_count = 0u64;
14291    let mut test_assertion_count = 0u64;
14292    let mut test_suite_count = 0u64;
14293    let mut mixed_lines_separate = 0u64;
14294    let mut coverage_lines_found = 0u64;
14295    let mut coverage_lines_hit = 0u64;
14296    let mut coverage_functions_found = 0u64;
14297    let mut coverage_functions_hit = 0u64;
14298    let mut coverage_branches_found = 0u64;
14299    let mut coverage_branches_hit = 0u64;
14300    for r in &sub_files {
14301        functions += r.raw_line_categories.functions;
14302        classes += r.raw_line_categories.classes;
14303        variables += r.raw_line_categories.variables;
14304        imports += r.raw_line_categories.imports;
14305        test_count += r.raw_line_categories.test_count;
14306        test_assertion_count += r.raw_line_categories.test_assertion_count;
14307        test_suite_count += r.raw_line_categories.test_suite_count;
14308        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
14309        if let Some(cov) = &r.coverage {
14310            coverage_lines_found += u64::from(cov.lines_found);
14311            coverage_lines_hit += u64::from(cov.lines_hit);
14312            coverage_functions_found += u64::from(cov.functions_found);
14313            coverage_functions_hit += u64::from(cov.functions_hit);
14314            coverage_branches_found += u64::from(cov.branches_found);
14315            coverage_branches_hit += u64::from(cov.branches_hit);
14316        }
14317    }
14318
14319    AnalysisRun {
14320        tool: parent.tool.clone(),
14321        environment: parent.environment.clone(),
14322        effective_configuration: config,
14323        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
14324        summary_totals: SummaryTotals {
14325            files_considered: sub.files_analyzed,
14326            files_analyzed: sub.files_analyzed,
14327            files_skipped: 0,
14328            total_physical_lines: sub.total_physical_lines,
14329            code_lines: sub.code_lines,
14330            comment_lines: sub.comment_lines,
14331            blank_lines: sub.blank_lines,
14332            mixed_lines_separate,
14333            functions,
14334            classes,
14335            variables,
14336            imports,
14337            test_count,
14338            test_assertion_count,
14339            test_suite_count,
14340            coverage_lines_found,
14341            coverage_lines_hit,
14342            coverage_functions_found,
14343            coverage_functions_hit,
14344            coverage_branches_found,
14345            coverage_branches_hit,
14346            cyclomatic_complexity: 0,
14347            lsloc: None,
14348        },
14349        totals_by_language: sub.language_summaries.clone(),
14350        per_file_records: sub_files,
14351        skipped_file_records: vec![],
14352        warnings: vec![],
14353        submodule_summaries: vec![],
14354        git_commit_short: sub.git_commit_short.clone(),
14355        git_commit_long: sub.git_commit_long.clone(),
14356        git_branch: sub.git_branch.clone(),
14357        git_commit_author: sub.git_commit_author.clone(),
14358        git_commit_date: sub.git_commit_date.clone(),
14359        git_tags: None,
14360        git_nearest_tag: None,
14361        git_remote_url: sub.git_remote_url.clone(),
14362        style_summary: None,
14363        cocomo: None,
14364        uloc: 0,
14365        dryness_pct: None,
14366        duplicate_groups: vec![],
14367        duplicates_excluded: 0,
14368    }
14369}
14370
14371#[must_use]
14372pub fn sanitize_project_label(raw: &str) -> String {
14373    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
14374    // where `Path` treats '\' as a literal character, not a separator.
14375    let candidate = raw
14376        .split(['/', '\\'])
14377        .rfind(|s| !s.is_empty())
14378        .unwrap_or("project");
14379
14380    let mut value = String::with_capacity(candidate.len());
14381    for ch in candidate.chars() {
14382        if ch.is_ascii_alphanumeric() {
14383            value.push(ch.to_ascii_lowercase());
14384        } else {
14385            value.push('-');
14386        }
14387    }
14388
14389    let compact = value.trim_matches('-').to_string();
14390    if compact.is_empty() {
14391        "project".to_string()
14392    } else {
14393        compact
14394    }
14395}
14396
14397/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
14398/// comparisons with non-canonicalized stored paths work correctly.
14399fn strip_unc_prefix(path: PathBuf) -> PathBuf {
14400    let s = path.to_string_lossy();
14401    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14402        return PathBuf::from(format!(r"\\{rest}"));
14403    }
14404    if let Some(rest) = s.strip_prefix(r"\\?\") {
14405        return PathBuf::from(rest);
14406    }
14407    path
14408}
14409
14410/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
14411/// commit page URL for the most common hosting platforms.
14412fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14413    let base = if let Some(rest) = remote.strip_prefix("git@") {
14414        let (host, path) = rest.split_once(':')?;
14415        format!("https://{}/{}", host, path.trim_end_matches(".git"))
14416    } else if remote.starts_with("https://") || remote.starts_with("http://") {
14417        remote
14418            .trim_end_matches('/')
14419            .trim_end_matches(".git")
14420            .to_owned()
14421    } else {
14422        return None;
14423    };
14424    let base = base.trim_end_matches('/');
14425    // GitLab uses /-/commit/; everything else uses /commit/
14426    if base.contains("gitlab.com") || base.contains("gitlab.") {
14427        Some(format!("{base}/-/commit/{sha}"))
14428    } else if base.contains("bitbucket.org") {
14429        Some(format!("{base}/commits/{sha}"))
14430    } else {
14431        Some(format!("{base}/commit/{sha}"))
14432    }
14433}
14434
14435/// Convert a git remote URL (https or git@) + branch name into a browser-openable
14436/// branch page URL for the most common hosting platforms.
14437fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
14438    let base = if let Some(rest) = remote.strip_prefix("git@") {
14439        let (host, path) = rest.split_once(':')?;
14440        format!("https://{}/{}", host, path.trim_end_matches(".git"))
14441    } else if remote.starts_with("https://") || remote.starts_with("http://") {
14442        remote
14443            .trim_end_matches('/')
14444            .trim_end_matches(".git")
14445            .to_owned()
14446    } else {
14447        return None;
14448    };
14449    let base = base.trim_end_matches('/');
14450    if base.contains("gitlab.com") || base.contains("gitlab.") {
14451        Some(format!("{base}/-/tree/{branch}"))
14452    } else {
14453        Some(format!("{base}/tree/{branch}"))
14454    }
14455}
14456
14457fn display_path(path: &Path) -> String {
14458    let s = path.to_string_lossy();
14459    // Strip Windows extended-length prefix for display only; the underlying
14460    // PathBuf remains unchanged so file operations are unaffected.
14461    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
14462    // \\?\C:\path           →  C:\path          (local drive)
14463    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14464        return format!(r"\\{rest}");
14465    }
14466    if let Some(rest) = s.strip_prefix(r"\\?\") {
14467        return rest.to_owned();
14468    }
14469    s.into_owned()
14470}
14471
14472fn sanitize_path_str(s: &str) -> String {
14473    // Forward-slash variants of the Windows extended-length prefix that appear
14474    // when paths stored as plain strings have been processed through some path
14475    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
14476    if let Some(rest) = s.strip_prefix("//?/UNC/") {
14477        return format!("//{rest}");
14478    }
14479    if let Some(rest) = s.strip_prefix("//?/") {
14480        return rest.to_owned();
14481    }
14482    display_path(Path::new(s))
14483}
14484
14485fn workspace_root() -> PathBuf {
14486    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
14487    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
14488        let p = PathBuf::from(root);
14489        if p.is_dir() {
14490            return p;
14491        }
14492    }
14493
14494    // Current working directory — works for `cargo run` from the project root
14495    // and for scripts/run.sh which cds there first.
14496    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
14497}
14498
14499/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
14500fn make_git_label(repo: &str, ref_name: &str) -> String {
14501    if repo.is_empty() || ref_name.is_empty() {
14502        return String::new();
14503    }
14504    let base = repo
14505        .trim_end_matches('/')
14506        .trim_end_matches(".git")
14507        .rsplit('/')
14508        .next()
14509        .unwrap_or("repo");
14510    let ref_safe: String = ref_name
14511        .chars()
14512        .map(|c| {
14513            if c.is_alphanumeric() || c == '-' || c == '.' {
14514                c
14515            } else {
14516                '_'
14517            }
14518        })
14519        .collect();
14520    format!("{base}_at_{ref_safe}_sloc")
14521}
14522
14523/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
14524fn desktop_dir() -> PathBuf {
14525    if let Ok(profile) = std::env::var("USERPROFILE") {
14526        let p = PathBuf::from(profile).join("Desktop");
14527        if p.exists() {
14528            return p;
14529        }
14530    }
14531    if let Ok(home) = std::env::var("HOME") {
14532        let p = PathBuf::from(home).join("Desktop");
14533        if p.exists() {
14534            return p;
14535        }
14536    }
14537    workspace_root().join("out").join("web")
14538}
14539
14540fn resolve_input_path(raw: &str) -> PathBuf {
14541    let trimmed = raw.trim();
14542    if trimmed.is_empty() {
14543        return workspace_root().join("samples").join("basic");
14544    }
14545
14546    let candidate = PathBuf::from(trimmed);
14547    let resolved = if candidate.is_absolute() {
14548        candidate
14549    } else {
14550        let rooted = workspace_root().join(&candidate);
14551        if rooted.exists() {
14552            rooted
14553        } else {
14554            workspace_root().join(candidate)
14555        }
14556    };
14557
14558    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
14559    // strip that prefix so stored paths and the displayed "Project path" are clean.
14560    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
14561    PathBuf::from(display_path(&canonical))
14562}
14563
14564fn dir_size_bytes(path: &Path) -> u64 {
14565    let mut total = 0u64;
14566    if let Ok(rd) = fs::read_dir(path) {
14567        for entry in rd.filter_map(Result::ok) {
14568            let p = entry.path();
14569            if p.is_file() {
14570                if let Ok(meta) = p.metadata() {
14571                    total += meta.len();
14572                }
14573            } else if p.is_dir() {
14574                total += dir_size_bytes(&p);
14575            }
14576        }
14577    }
14578    total
14579}
14580
14581#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
14582fn format_dir_size(bytes: u64) -> String {
14583    if bytes >= 1_073_741_824 {
14584        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
14585    } else if bytes >= 1_048_576 {
14586        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
14587    } else if bytes >= 1_024 {
14588        format!("{:.0} KB", bytes as f64 / 1_024.0)
14589    } else {
14590        format!("{bytes} B")
14591    }
14592}
14593
14594fn render_submodule_chips(
14595    root: &Path,
14596    submodules: &[(String, std::path::PathBuf)],
14597    out: &mut String,
14598) {
14599    use std::fmt::Write as _;
14600    let count = submodules.len();
14601    out.push_str(r#"<div class="submodule-preview-strip">"#);
14602    write!(
14603        out,
14604        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>"#,
14605        if count == 1 { "" } else { "s" }
14606    )
14607    .ok();
14608    out.push_str(r#"<div class="submodule-preview-chips">"#);
14609    for (sub_name, sub_rel_path) in submodules {
14610        let sub_abs = root.join(sub_rel_path);
14611        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
14612        let mut sub_stats = PreviewStats::default();
14613        let mut sub_rows: Vec<PreviewRow> = Vec::new();
14614        let mut sub_langs: Vec<&'static str> = Vec::new();
14615        let mut sub_budget = PreviewBudget {
14616            shown: 0,
14617            max_entries: 2000,
14618            max_depth: 9,
14619        };
14620        let mut sub_next_id = 1usize;
14621        let _ = collect_preview_rows(
14622            &sub_abs,
14623            &sub_abs,
14624            0,
14625            None,
14626            &mut sub_next_id,
14627            &mut sub_budget,
14628            &mut sub_stats,
14629            &mut sub_rows,
14630            &mut sub_langs,
14631            &[],
14632            &[],
14633        );
14634        let stats_json = format!(
14635            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
14636            sub_stats.directories,
14637            sub_stats.files,
14638            sub_stats.supported,
14639            sub_stats.skipped,
14640            sub_stats.unsupported
14641        );
14642        write!(
14643            out,
14644            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>"#,
14645            escape_html(sub_name),
14646            escape_html(&sub_rel_path.to_string_lossy()),
14647            escape_html(&sub_size),
14648            escape_html(&stats_json),
14649            escape_html(sub_name),
14650            escape_html(&sub_size),
14651        )
14652        .ok();
14653    }
14654    out.push_str(
14655        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
14656    );
14657    out.push_str(r"</div>");
14658}
14659
14660fn render_language_pills_row(languages: &[&str], out: &mut String) {
14661    use std::fmt::Write as _;
14662    if languages.is_empty() {
14663        out.push_str(
14664            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
14665        );
14666        return;
14667    }
14668    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
14669    for language in languages {
14670        if let Some(icon) = language_icon_file(language) {
14671            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();
14672        } else if let Some(svg) = language_inline_svg(language) {
14673            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();
14674        } else {
14675            write!(
14676                out,
14677                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
14678                escape_html(&language.to_ascii_lowercase()),
14679                escape_html(language)
14680            )
14681            .ok();
14682        }
14683    }
14684}
14685
14686#[allow(clippy::too_many_lines)]
14687fn build_preview_html(
14688    root: &Path,
14689    include_patterns: &[String],
14690    exclude_patterns: &[String],
14691) -> Result<String> {
14692    if !root.exists() {
14693        return Ok(format!(
14694            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
14695            escape_html(&display_path(root))
14696        ));
14697    }
14698
14699    let _selected = display_path(root);
14700    let mut stats = PreviewStats::default();
14701    let mut rows = Vec::new();
14702    let mut languages = Vec::new();
14703    let mut budget = PreviewBudget {
14704        shown: 0,
14705        max_entries: 600,
14706        max_depth: 9,
14707    };
14708    let mut next_row_id = 1usize;
14709
14710    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
14711        || root.to_string_lossy().into_owned(),
14712        std::string::ToString::to_string,
14713    );
14714    let root_modified = root
14715        .metadata()
14716        .ok()
14717        .and_then(|meta| meta.modified().ok())
14718        .map_or_else(|| "-".to_string(), format_system_time);
14719
14720    rows.push(PreviewRow {
14721        row_id: 0,
14722        parent_row_id: None,
14723        depth: 0,
14724        name: format!("{root_name}/"),
14725        kind: PreviewKind::Dir,
14726        is_dir: true,
14727        language: None,
14728        modified: root_modified,
14729        type_label: "Directory".to_string(),
14730    });
14731    collect_preview_rows(
14732        root,
14733        root,
14734        0,
14735        Some(0),
14736        &mut next_row_id,
14737        &mut budget,
14738        &mut stats,
14739        &mut rows,
14740        &mut languages,
14741        include_patterns,
14742        exclude_patterns,
14743    )?;
14744
14745    let root_size = format_dir_size(dir_size_bytes(root));
14746
14747    let mut out = String::new();
14748    write!(
14749        out,
14750        r#"<div class="explorer-wrap" data-project-size="{}">"#,
14751        escape_html(&root_size)
14752    )
14753    .ok();
14754    out.push_str(r#"<div class="explorer-toolbar compact">"#);
14755    out.push_str(r#"<div class="explorer-title-group">"#);
14756    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
14757    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
14758    out.push_str(r"</div></div>");
14759
14760    out.push_str(r#"<div class="scope-stats">"#);
14761    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();
14762    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();
14763    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();
14764    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();
14765    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();
14766    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>"#);
14767    out.push_str(r"</div>");
14768
14769    let submodules = sloc_core::detect_submodules(root);
14770    if !submodules.is_empty() {
14771        render_submodule_chips(root, &submodules, &mut out);
14772    }
14773
14774    out.push_str(r#"<div class="scope-info-row">"#);
14775    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
14776    render_language_pills_row(&languages, &mut out);
14777    out.push_str(r"</div></div>");
14778    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>"#);
14779    out.push_str(r"</div>");
14780
14781    out.push_str(r#"<div class="file-explorer-shell">"#);
14782    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>"#);
14783    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>"#);
14784    out.push_str(r#"<div class="file-explorer-tree">"#);
14785    for row in rows {
14786        let status_label = row.kind.label();
14787        let lang_attr = row.language.unwrap_or("");
14788        let toggle_html = if row.is_dir {
14789            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
14790                .to_string()
14791        } else {
14792            r#"<span class="tree-bullet">•</span>"#.to_string()
14793        };
14794        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();
14795    }
14796    if budget.shown >= budget.max_entries {
14797        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>"#);
14798    }
14799    out.push_str(r"</div></div></div>");
14800
14801    Ok(out)
14802}
14803
14804#[derive(Default)]
14805struct PreviewStats {
14806    directories: usize,
14807    files: usize,
14808    supported: usize,
14809    skipped: usize,
14810    unsupported: usize,
14811}
14812
14813struct PreviewRow {
14814    row_id: usize,
14815    parent_row_id: Option<usize>,
14816    depth: usize,
14817    name: String,
14818    kind: PreviewKind,
14819    is_dir: bool,
14820    language: Option<&'static str>,
14821    modified: String,
14822    type_label: String,
14823}
14824
14825#[derive(Copy, Clone)]
14826enum PreviewKind {
14827    Dir,
14828    Supported,
14829    Skipped,
14830    Unsupported,
14831}
14832
14833impl PreviewKind {
14834    const fn filter_key(self) -> &'static str {
14835        match self {
14836            Self::Dir => "dir",
14837            Self::Supported => "supported",
14838            Self::Skipped => "skipped",
14839            Self::Unsupported => "unsupported",
14840        }
14841    }
14842
14843    const fn label(self) -> &'static str {
14844        match self {
14845            Self::Dir => "dir",
14846            Self::Supported => "supported",
14847            Self::Skipped => "skipped by policy",
14848            Self::Unsupported => "unsupported",
14849        }
14850    }
14851
14852    const fn badge_class(self) -> &'static str {
14853        match self {
14854            Self::Dir => "badge badge-dir",
14855            Self::Supported => "badge badge-scan",
14856            Self::Skipped => "badge badge-skip",
14857            Self::Unsupported => "badge badge-unsupported",
14858        }
14859    }
14860
14861    const fn node_class(self) -> &'static str {
14862        match self {
14863            Self::Dir => "tree-node-dir",
14864            Self::Supported => "tree-node-supported",
14865            Self::Skipped => "tree-node-skipped",
14866            Self::Unsupported => "tree-node-unsupported",
14867        }
14868    }
14869}
14870
14871struct PreviewBudget {
14872    shown: usize,
14873    max_entries: usize,
14874    max_depth: usize,
14875}
14876
14877/// Handle a single directory entry inside `collect_preview_rows`.
14878/// Returns `true` when the entry was handled (caller should `continue`).
14879#[allow(clippy::too_many_arguments)]
14880fn handle_preview_dir_entry(
14881    root: &Path,
14882    path: &Path,
14883    name: &str,
14884    modified: String,
14885    depth: usize,
14886    parent_row_id: Option<usize>,
14887    row_id: usize,
14888    next_row_id: &mut usize,
14889    budget: &mut PreviewBudget,
14890    stats: &mut PreviewStats,
14891    rows: &mut Vec<PreviewRow>,
14892    languages: &mut Vec<&'static str>,
14893    include_patterns: &[String],
14894    exclude_patterns: &[String],
14895) -> Result<()> {
14896    let relative = preview_relative_path(root, path);
14897    if should_skip_preview_directory(&relative, exclude_patterns) {
14898        return Ok(());
14899    }
14900    stats.directories += 1;
14901    rows.push(PreviewRow {
14902        row_id,
14903        parent_row_id,
14904        depth: depth + 1,
14905        name: format!("{name}/"),
14906        kind: PreviewKind::Dir,
14907        is_dir: true,
14908        language: None,
14909        modified,
14910        type_label: "Directory".to_string(),
14911    });
14912    budget.shown += 1;
14913    if !matches!(name, ".git" | "node_modules" | "target") {
14914        collect_preview_rows(
14915            root,
14916            path,
14917            depth + 1,
14918            Some(row_id),
14919            next_row_id,
14920            budget,
14921            stats,
14922            rows,
14923            languages,
14924            include_patterns,
14925            exclude_patterns,
14926        )?;
14927    }
14928    Ok(())
14929}
14930
14931/// Handle a single file entry inside `collect_preview_rows`.
14932#[allow(clippy::too_many_arguments)]
14933fn handle_preview_file_entry(
14934    root: &Path,
14935    path: &Path,
14936    name: &str,
14937    modified: String,
14938    depth: usize,
14939    parent_row_id: Option<usize>,
14940    row_id: usize,
14941    budget: &mut PreviewBudget,
14942    stats: &mut PreviewStats,
14943    rows: &mut Vec<PreviewRow>,
14944    languages: &mut Vec<&'static str>,
14945    include_patterns: &[String],
14946    exclude_patterns: &[String],
14947) {
14948    let relative = preview_relative_path(root, path);
14949    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
14950        return;
14951    }
14952    stats.files += 1;
14953    let kind = classify_preview_file(name);
14954    match kind {
14955        PreviewKind::Supported => stats.supported += 1,
14956        PreviewKind::Skipped => stats.skipped += 1,
14957        PreviewKind::Unsupported => stats.unsupported += 1,
14958        PreviewKind::Dir => {}
14959    }
14960    let language = detect_language_name(name);
14961    if let Some(lang) = language {
14962        if !languages.contains(&lang) {
14963            languages.push(lang);
14964        }
14965    }
14966    rows.push(PreviewRow {
14967        row_id,
14968        parent_row_id,
14969        depth: depth + 1,
14970        name: name.to_owned(),
14971        kind,
14972        is_dir: false,
14973        language,
14974        modified,
14975        type_label: preview_type_label(name, language, kind),
14976    });
14977    budget.shown += 1;
14978}
14979
14980#[allow(clippy::too_many_arguments)]
14981#[allow(clippy::too_many_lines)]
14982fn collect_preview_rows(
14983    root: &Path,
14984    dir: &Path,
14985    depth: usize,
14986    parent_row_id: Option<usize>,
14987    next_row_id: &mut usize,
14988    budget: &mut PreviewBudget,
14989    stats: &mut PreviewStats,
14990    rows: &mut Vec<PreviewRow>,
14991    languages: &mut Vec<&'static str>,
14992    include_patterns: &[String],
14993    exclude_patterns: &[String],
14994) -> Result<()> {
14995    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
14996        return Ok(());
14997    }
14998
14999    let mut entries = fs::read_dir(dir)
15000        .with_context(|| format!("failed to read directory {}", dir.display()))?
15001        .filter_map(std::result::Result::ok)
15002        .collect::<Vec<_>>();
15003    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
15004
15005    for entry in entries {
15006        if budget.shown >= budget.max_entries {
15007            break;
15008        }
15009
15010        let path = entry.path();
15011        let name = entry.file_name().to_string_lossy().into_owned();
15012        let Ok(metadata) = entry.metadata() else {
15013            continue;
15014        };
15015        let row_id = *next_row_id;
15016        *next_row_id += 1;
15017        let modified = metadata
15018            .modified()
15019            .ok()
15020            .map_or_else(|| "-".to_string(), format_system_time);
15021
15022        if metadata.is_dir() {
15023            handle_preview_dir_entry(
15024                root,
15025                &path,
15026                &name,
15027                modified,
15028                depth,
15029                parent_row_id,
15030                row_id,
15031                next_row_id,
15032                budget,
15033                stats,
15034                rows,
15035                languages,
15036                include_patterns,
15037                exclude_patterns,
15038            )?;
15039            continue;
15040        }
15041
15042        if metadata.is_file() {
15043            handle_preview_file_entry(
15044                root,
15045                &path,
15046                &name,
15047                modified,
15048                depth,
15049                parent_row_id,
15050                row_id,
15051                budget,
15052                stats,
15053                rows,
15054                languages,
15055                include_patterns,
15056                exclude_patterns,
15057            );
15058        }
15059    }
15060
15061    Ok(())
15062}
15063
15064fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
15065    if let Some(language) = language {
15066        return format!("{language} source");
15067    }
15068    let lower = name.to_ascii_lowercase();
15069    let ext = Path::new(&lower)
15070        .extension()
15071        .and_then(|e| e.to_str())
15072        .unwrap_or("");
15073    match kind {
15074        PreviewKind::Skipped => {
15075            if lower.ends_with(".min.js") {
15076                "Minified asset".to_string()
15077            } else if [
15078                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
15079            ]
15080            .contains(&ext)
15081            {
15082                "Binary or archive".to_string()
15083            } else {
15084                "Skipped file".to_string()
15085            }
15086        }
15087        PreviewKind::Unsupported => {
15088            if ext.is_empty() {
15089                "Unsupported file".to_string()
15090            } else {
15091                format!("{} file", ext.to_ascii_uppercase())
15092            }
15093        }
15094        PreviewKind::Supported => "Supported source".to_string(),
15095        PreviewKind::Dir => "Directory".to_string(),
15096    }
15097}
15098
15099fn format_system_time(time: SystemTime) -> String {
15100    #[allow(clippy::cast_possible_wrap)]
15101    let secs = match time.duration_since(UNIX_EPOCH) {
15102        Ok(duration) => duration.as_secs() as i64,
15103        Err(_) => return "-".to_string(),
15104    };
15105    let days = secs.div_euclid(86_400);
15106    let secs_of_day = secs.rem_euclid(86_400);
15107    let (year, month, day) = civil_from_days(days);
15108    let hour = secs_of_day / 3_600;
15109    let minute = (secs_of_day % 3_600) / 60;
15110    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
15111}
15112
15113#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
15114fn civil_from_days(days: i64) -> (i32, u32, u32) {
15115    let z = days + 719_468;
15116    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
15117    let doe = z - era * 146_097;
15118    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
15119    let y = yoe + era * 400;
15120    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
15121    let mp = (5 * doy + 2) / 153;
15122    let d = doy - (153 * mp + 2) / 5 + 1;
15123    let m = mp + if mp < 10 { 3 } else { -9 };
15124    let year = y + i64::from(m <= 2);
15125    (year as i32, m as u32, d as u32)
15126}
15127
15128// The input is already lowercased via `to_ascii_lowercase()` before calling
15129// `ends_with`, so the comparisons are inherently case-insensitive.
15130#[allow(clippy::case_sensitive_file_extension_comparisons)]
15131fn detect_language_name(name: &str) -> Option<&'static str> {
15132    let lower = name.to_ascii_lowercase();
15133    if lower.ends_with(".c") || lower.ends_with(".h") {
15134        Some("C")
15135    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
15136        .iter()
15137        .any(|s| lower.ends_with(s))
15138    {
15139        Some("C++")
15140    } else if lower.ends_with(".cs") {
15141        Some("C#")
15142    } else if lower.ends_with(".py") {
15143        Some("Python")
15144    } else if lower.ends_with(".sh") {
15145        Some("Shell")
15146    } else if [".ps1", ".psm1", ".psd1"]
15147        .iter()
15148        .any(|s| lower.ends_with(s))
15149    {
15150        Some("PowerShell")
15151    } else {
15152        None
15153    }
15154}
15155
15156fn language_icon_file(language: &str) -> Option<&'static str> {
15157    match language {
15158        "C" => Some("c.png"),
15159        "C++" => Some("cpp.png"),
15160        "C#" => Some("c-sharp.png"),
15161        "Python" => Some("python.png"),
15162        "Shell" => Some("shell.png"),
15163        "PowerShell" => Some("powershell.png"),
15164        "JavaScript" => Some("java-script.png"),
15165        "HTML" => Some("html-5.png"),
15166        "Java" => Some("java.png"),
15167        "Visual Basic" => Some("visual-basic.png"),
15168        "Assembly" => Some("asm.png"),
15169        "Go" => Some("go.png"),
15170        "R" => Some("r.png"),
15171        "XML" => Some("xml.png"),
15172        "Groovy" => Some("groovy.png"),
15173        "Dockerfile" => Some("docker.png"),
15174        "Makefile" => Some("makefile.svg"),
15175        "Perl" => Some("perl.svg"),
15176        _ => None,
15177    }
15178}
15179
15180// Inline SVG badges for languages that have no PNG icon in images/icons/.
15181// Using inline SVG keeps the web UI fully self-contained — no extra files
15182// needed on disk, no 404s on air-gapped deployments.
15183// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
15184fn language_inline_svg(language: &str) -> Option<&'static str> {
15185    match language {
15186        "Rust" => Some(
15187            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>"##,
15188        ),
15189        "TypeScript" => Some(
15190            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>"##,
15191        ),
15192        _ => None,
15193    }
15194}
15195
15196// The input is already lowercased via `to_ascii_lowercase()` before the
15197// `ends_with` calls, so these comparisons are inherently case-insensitive.
15198#[allow(clippy::case_sensitive_file_extension_comparisons)]
15199fn classify_preview_file(name: &str) -> PreviewKind {
15200    let lower = name.to_ascii_lowercase();
15201
15202    let scannable = [
15203        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
15204        ".psm1", ".psd1",
15205    ]
15206    .iter()
15207    .any(|suffix| lower.ends_with(suffix));
15208
15209    if scannable {
15210        PreviewKind::Supported
15211    } else if lower.ends_with(".min.js")
15212        || lower.ends_with(".lock")
15213        || lower.ends_with(".png")
15214        || lower.ends_with(".jpg")
15215        || lower.ends_with(".jpeg")
15216        || lower.ends_with(".gif")
15217        || lower.ends_with(".zip")
15218        || lower.ends_with(".pdf")
15219        || lower.ends_with(".pyc")
15220        || lower.ends_with(".xz")
15221        || lower.ends_with(".tar")
15222        || lower.ends_with(".gz")
15223    {
15224        PreviewKind::Skipped
15225    } else {
15226        PreviewKind::Unsupported
15227    }
15228}
15229
15230fn preview_relative_path(root: &Path, path: &Path) -> String {
15231    path.strip_prefix(root)
15232        .ok()
15233        .unwrap_or(path)
15234        .to_string_lossy()
15235        .replace('\\', "/")
15236        .trim_matches('/')
15237        .to_string()
15238}
15239
15240fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
15241    if relative.is_empty() {
15242        return false;
15243    }
15244
15245    exclude_patterns.iter().any(|pattern| {
15246        wildcard_match(pattern, relative)
15247            || wildcard_match(pattern, &format!("{relative}/"))
15248            || wildcard_match(pattern, &format!("{relative}/placeholder"))
15249    })
15250}
15251
15252fn should_include_preview_file(
15253    relative: &str,
15254    include_patterns: &[String],
15255    exclude_patterns: &[String],
15256) -> bool {
15257    if relative.is_empty() {
15258        return true;
15259    }
15260
15261    let included = include_patterns.is_empty()
15262        || include_patterns
15263            .iter()
15264            .any(|pattern| wildcard_match(pattern, relative));
15265    let excluded = exclude_patterns
15266        .iter()
15267        .any(|pattern| wildcard_match(pattern, relative));
15268
15269    included && !excluded
15270}
15271
15272fn wildcard_match(pattern: &str, candidate: &str) -> bool {
15273    let pattern = pattern.trim().replace('\\', "/");
15274    let candidate = candidate.trim().replace('\\', "/");
15275    let p = pattern.as_bytes();
15276    let c = candidate.as_bytes();
15277    let mut pi = 0usize;
15278    let mut ci = 0usize;
15279    let mut star: Option<usize> = None;
15280    let mut star_match = 0usize;
15281
15282    while ci < c.len() {
15283        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
15284            pi += 1;
15285            ci += 1;
15286        } else if pi < p.len() && p[pi] == b'*' {
15287            while pi < p.len() && p[pi] == b'*' {
15288                pi += 1;
15289            }
15290            star = Some(pi);
15291            star_match = ci;
15292        } else if let Some(star_pi) = star {
15293            star_match += 1;
15294            ci = star_match;
15295            pi = star_pi;
15296        } else {
15297            return false;
15298        }
15299    }
15300
15301    while pi < p.len() && p[pi] == b'*' {
15302        pi += 1;
15303    }
15304
15305    pi == p.len()
15306}
15307
15308fn escape_html(value: &str) -> String {
15309    value
15310        .replace('&', "&amp;")
15311        .replace('<', "&lt;")
15312        .replace('>', "&gt;")
15313        .replace('"', "&quot;")
15314        .replace('\'', "&#39;")
15315}
15316
15317#[derive(Clone)]
15318struct SubmoduleRow {
15319    name: String,
15320    relative_path: String,
15321    files_analyzed: u64,
15322    code_lines: u64,
15323    comment_lines: u64,
15324    blank_lines: u64,
15325    total_physical_lines: u64,
15326    html_url: Option<String>,
15327}
15328
15329#[derive(Template)]
15330#[template(
15331    source = r##"
15332<!doctype html>
15333<html lang="en">
15334<head>
15335  <meta charset="utf-8">
15336  <title>OxideSLOC | tmp-sloc</title>
15337  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15338  <style nonce="{{ csp_nonce }}">
15339    :root {
15340      --bg: #efe9e2;
15341      --surface: #fcfaf7;
15342      --surface-2: #f7f0e8;
15343      --surface-3: #efe3d5;
15344      --line: #dfcfbf;
15345      --line-strong: #cfb29c;
15346      --text: #2f241c;
15347      --muted: #6f6257;
15348      --muted-2: #917f71;
15349      --nav: #b85d33;
15350      --nav-2: #7a371b;
15351      --accent: #2563eb;
15352      --accent-2: #1d4ed8;
15353      --oxide: #b85d33;
15354      --oxide-2: #8f4220;
15355      --success-bg: #eaf9ee;
15356      --success-text: #1c8746;
15357      --warn-bg: #fff2d8;
15358      --warn-text: #926000;
15359      --danger-bg: #fdeaea;
15360      --danger-text: #b33b3b;
15361      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
15362      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
15363      --radius: 14px;
15364    }
15365
15366    body.dark-theme {
15367      --bg: #1b1511;
15368      --surface: #261c17;
15369      --surface-2: #2d221d;
15370      --surface-3: #372922;
15371      --line: #524238;
15372      --line-strong: #6c5649;
15373      --text: #f5ece6;
15374      --muted: #c7b7aa;
15375      --muted-2: #aa9485;
15376      --nav: #b85d33;
15377      --nav-2: #7a371b;
15378      --accent: #6f9bff;
15379      --accent-2: #4a78ee;
15380      --oxide: #d37a4c;
15381      --oxide-2: #b35428;
15382      --success-bg: #163927;
15383      --success-text: #8fe2a8;
15384      --warn-bg: #3c2d11;
15385      --warn-text: #f3cb75;
15386      --danger-bg: #3d1f1f;
15387      --danger-text: #ff9f9f;
15388      --shadow: 0 14px 28px rgba(0,0,0,0.28);
15389      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
15390    }
15391
15392    * { box-sizing: border-box; }
15393    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); }
15394    html { overflow-y: scroll; }
15395    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15396    .top-nav, .page, .loading { position: relative; z-index: 2; }
15397    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15398    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15399    .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); }
15400    .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; }
15401    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15402    .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)); }
15403    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15404    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15405    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15406    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15407    .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; }
15408    .nav-project-pill.visible { display:inline-flex; }
15409    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15410    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15411    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15412    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15413    @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; } }
15414    .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; }
15415    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
15416    .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; }
15417    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15418    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15419    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15420    .theme-toggle .icon-sun { display:none; }
15421    body.dark-theme .theme-toggle .icon-sun { display:block; }
15422    body.dark-theme .theme-toggle .icon-moon { display:none; }
15423    .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;}
15424    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15425    .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);}
15426    .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;}
15427    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15428    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15429    .settings-modal-body{padding:14px 16px 16px;}
15430    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15431    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15432    .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;}
15433    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15434    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15435    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15436    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15437    .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;}
15438    .tz-select:focus{border-color:var(--oxide);}
15439    .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; }
15440    .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;}
15441    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
15442    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
15443    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
15444    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
15445    .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; }
15446    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
15447    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
15448    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
15449    .wb-stats-header { padding: 10px 24px 0; }
15450    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
15451    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
15452    .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; }
15453    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15454    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15455    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15456    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
15457    .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; }
15458    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
15459    .ws-stat-analyzers { position: relative; }
15460    .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; }
15461    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
15462    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
15463    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
15464    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
15465    .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; }
15466    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
15467    .ws-divider { display: none; }
15468    .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%; }
15469    .ws-path-link:hover { color:var(--oxide); }
15470    body.dark-theme .ws-path-link { color:var(--oxide); }
15471    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
15472    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15473    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
15474    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15475    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
15476    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
15477    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
15478    .ws-mini-box-lg { flex:2 1 0; }
15479    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
15480    .ws-mini-box-br { flex:1.5 1 0; }
15481    .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); }
15482    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
15483    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
15484    #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; }
15485    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
15486    .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; }
15487    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
15488    .git-source-banner strong { font-weight:800; color:var(--text); }
15489    .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; }
15490    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
15491    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
15492    .git-source-banner a:hover { text-decoration:underline; }
15493    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
15494    .path-scope-sep { background:var(--line); margin:4px 14px; }
15495    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
15496    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
15497    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
15498    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
15499    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
15500    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
15501    .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; }
15502    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15503    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15504    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15505    .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; }
15506    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
15507    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
15508    [data-wb-tip] { cursor:help; }
15509    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
15510    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
15511    .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; }
15512    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
15513    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
15514    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
15515    .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; }
15516    .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); }
15517    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
15518    .side-info-card { padding: 18px; }
15519    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
15520    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
15521    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
15522    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
15523    .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); }
15524    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
15525    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15526    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
15527    .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; }
15528    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
15529    .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; }
15530    .side-stack::-webkit-scrollbar { display: none; }
15531    .step-nav { padding: 20px 16px; }
15532    .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); }
15533    .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; }
15534    .step-button:hover { background: var(--surface-2); }
15535    .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); }
15536    .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; }
15537    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15538    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
15539    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
15540    .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); }
15541    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
15542    .step-nav-sum-row:last-child { border-bottom:none; }
15543    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
15544    .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; }
15545    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
15546    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
15547    .quick-scan-section { padding: 10px 4px 14px; }
15548    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
15549    .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; }
15550    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
15551    .quick-scan-btn:active { transform:translateY(0); }
15552    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
15553    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
15554    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
15555    @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);} }
15556    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
15557    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
15558    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
15559    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
15560    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
15561    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
15562    .step-button.done .step-check { opacity:1; }
15563    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
15564    .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; }
15565    .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; }
15566    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
15567    .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; }
15568    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
15569    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
15570    .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; }
15571    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
15572    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15573    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
15574    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
15575    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
15576    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
15577    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
15578    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
15579    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
15580    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
15581    .card-body { padding: 22px; }
15582    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
15583    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
15584    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
15585    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
15586    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
15587    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
15588    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
15589    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
15590    .field { min-width:0; }
15591    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
15592    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; }
15593    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); }
15594    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
15595    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); }
15596    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
15597    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
15598    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
15599    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15600    .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; }
15601    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
15602    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
15603    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
15604    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
15605    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
15606    .input-group.compact { grid-template-columns: 1fr auto auto; }
15607    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
15608    .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)); }
15609    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
15610    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
15611    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
15612    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
15613    .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; }
15614    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
15615    .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; }
15616    .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); }
15617    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
15618    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
15619    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
15620    button.secondary { background: var(--surface); }
15621    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15622    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
15623    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
15624    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15625    .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); }
15626    .section + .wizard-actions { border-top: none; padding-top: 0; }
15627    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
15628    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15629    .field-help-grid.coupled-help { margin-top: 12px; }
15630    .field-help-grid.preset-grid { align-items: start; }
15631    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
15632    .preset-inline-row .field { margin: 0; }
15633    .preset-inline-row .explainer-card { margin: 0; }
15634    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
15635    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
15636    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
15637    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
15638    .preset-kv-row > :last-child { flex:1; min-width:0; }
15639    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
15640    .output-field-row .field { margin: 0; }
15641    .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; }
15642    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
15643    .step3-subtitle { margin-bottom: 10px; max-width: none; }
15644    .counting-intro { margin-bottom: 8px; max-width: none; }
15645    .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; }
15646    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
15647    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
15648    .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; }
15649    .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; }
15650    .section-spacer-top { margin-top: 28px; }
15651    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
15652    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
15653    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
15654    .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); }
15655    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
15656    .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; }
15657    .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; }
15658    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
15659    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15660    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
15661    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
15662    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
15663    .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; }
15664    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
15665    .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); }
15666    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; }
15667    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; }
15668    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
15669    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
15670    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
15671    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
15672    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
15673    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
15674    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
15675    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
15676    .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); }
15677    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
15678    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
15679    .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; }
15680    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
15681    .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; }
15682    .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; }
15683    .always-tracked-tip-body { flex:1; min-width:0; }
15684    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
15685    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
15686    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
15687    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
15688    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
15689    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
15690    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
15691    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
15692    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
15693    .advanced-rule-description strong { color: var(--text); }
15694    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
15695    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
15696    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
15697    .review-link:hover { text-decoration: underline; }
15698    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
15699    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15700    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
15701    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
15702    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
15703    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
15704    .review-card ul { padding-left: 18px; margin: 0; }
15705    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
15706    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
15707    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
15708    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
15709    .review-card { min-height: 0; }
15710    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
15711    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
15712    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
15713    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
15714    .lang-overflow-chip { position:relative; cursor:default; }
15715    .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; }
15716    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
15717    .git-inline-row { align-items:start; }
15718    .mixed-line-card { display:flex; flex-direction:column; }
15719    .preset-inline-row .toggle-card { justify-content: center; }
15720        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
15721    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
15722    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
15723    .explorer-title { font-size: 18px; font-weight: 850; }
15724    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
15725    .explorer-subtitle.wide { max-width: none; }
15726    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
15727    .better-spacing { align-items:flex-start; justify-content:flex-end; }
15728    .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; }
15729    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
15730    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
15731    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
15732    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
15733    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
15734    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
15735    .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; }
15736    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
15737    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
15738    .scope-stat-button.supported { background: var(--success-bg); }
15739    .scope-stat-button.skipped { background: var(--warn-bg); }
15740    .scope-stat-button.unsupported { background: var(--danger-bg); }
15741    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
15742    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
15743    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
15744    [data-tooltip] { position: relative; }
15745    [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); }
15746    [data-tooltip]:hover::after { display: block; }
15747    .scope-stat-button[data-tooltip] { cursor: pointer; }
15748    .badge[data-tooltip] { cursor: help; }
15749    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
15750    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
15751    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
15752    .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; }
15753    .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; }
15754    code { display:inline-block; margin-top:0; padding:2px 7px; }
15755    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15756    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
15757    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
15758    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
15759    .language-pill.muted-pill { color: var(--muted); }
15760    button.language-pill { appearance:none; cursor:pointer; }
15761    .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); }
15762    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
15763    .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; }
15764    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
15765    .file-explorer-search-row { margin-left: auto; }
15766    .explorer-filter-select { min-width: 170px; width: 170px; }
15767    .explorer-search { min-width: 300px; width: 300px; }
15768    .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); }
15769    .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; }
15770    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
15771    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
15772    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
15773    .file-explorer-tree { max-height: 640px; overflow:auto; }
15774    .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); }
15775    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
15776    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
15777    .tree-row.hidden-by-filter { display:none !important; }
15778    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
15779    .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; }
15780    .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; }
15781    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
15782    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
15783    .tree-node { display:inline-flex; align-items:center; min-width:0; }
15784    .tree-node-dir { color: var(--text); font-weight: 800; }
15785    .tree-node-supported { color: var(--success-text); }
15786    .tree-node-skipped { color: var(--warn-text); }
15787    .tree-node-unsupported { color: var(--danger-text); }
15788    .tree-node-more { color: var(--muted-2); font-style: italic; }
15789    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
15790    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
15791    .tree-status-cell { display:flex; justify-content:flex-start; }
15792    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
15793    .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; }
15794    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15795    .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; }
15796    @keyframes prevSpin { to { transform:rotate(360deg); } }
15797    .preview-loading-text { flex:1; min-width:0; }
15798    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
15799    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
15800    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
15801    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
15802    .cov-scan-idle { display:none; }
15803    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
15804    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
15805    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
15806    .cov-scan-title { font-weight:600; font-size:12.5px; }
15807    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
15808    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
15809    .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; }
15810    .cov-scan-use:hover { opacity:.75; }
15811    .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; }
15812    .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; }
15813    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
15814    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
15815    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
15816    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
15817    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
15818    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
15819    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
15820    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
15821    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
15822    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
15823    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
15824    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
15825    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
15826    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
15827    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
15828    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
15829    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
15830    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
15831    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
15832    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
15833    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
15834    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
15835    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
15836    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
15837    .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); }
15838    .loading.active { display:flex; }
15839    .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; }
15840    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
15841    .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; }
15842    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
15843    .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); }
15844    .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
15845    .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
15846    .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
15847    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
15848    @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
15849    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
15850    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
15851    .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; }
15852    .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
15853    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
15854    .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
15855    .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
15856    .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; }
15857    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
15858    .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; }
15859    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
15860    .lc-step.done { color:var(--muted);opacity:0.55; }
15861    .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; }
15862    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
15863    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
15864    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
15865    .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; }
15866    .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; }
15867    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
15868    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
15869    .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; }
15870    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
15871    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
15872    .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; }
15873    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
15874    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
15875    .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; }
15876    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
15877    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
15878    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
15879    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
15880    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
15881    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
15882    .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; }
15883    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
15884    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
15885    .hidden { display:none !important; }
15886    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15887    .site-footer a{color:var(--muted);}
15888    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
15889    @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; } }
15890    .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;}
15891    @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));}}
15892    .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;}
15893    .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; }
15894    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
15895    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
15896    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
15897    .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; }
15898    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
15899    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
15900    .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; }
15901    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
15902    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
15903    .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; }
15904    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
15905    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
15906    .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; }
15907    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
15908    .info-icon-btn:hover { color:var(--text); }
15909    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); }
15910    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
15911    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
15912    .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;}
15913    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15914    .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;}
15915    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15916    #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);}
15917    #offline-file-banner.show{display:flex;}
15918    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
15919    #offline-file-banner .ofb-text{flex:1;}
15920    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
15921    #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;}
15922    #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;}
15923    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
15924    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
15925    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
15926    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
15927    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
15928    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
15929    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
15930  </style>
15931</head>
15932<body id="page-top">
15933  <div id="offline-file-banner" role="alert">
15934    <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>
15935    <span class="ofb-text">
15936      Charts, images, and navigation require the oxide-sloc server.
15937      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
15938      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
15939      The metric tables below are fully readable without the server.
15940    </span>
15941    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
15942  </div>
15943  <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>
15944  <div class="background-watermarks" aria-hidden="true">
15945    <img src="/images/logo/logo-text.png" alt="" />
15946    <img src="/images/logo/logo-text.png" alt="" />
15947    <img src="/images/logo/logo-text.png" alt="" />
15948    <img src="/images/logo/logo-text.png" alt="" />
15949    <img src="/images/logo/logo-text.png" alt="" />
15950    <img src="/images/logo/logo-text.png" alt="" />
15951    <img src="/images/logo/logo-text.png" alt="" />
15952    <img src="/images/logo/logo-text.png" alt="" />
15953    <img src="/images/logo/logo-text.png" alt="" />
15954    <img src="/images/logo/logo-text.png" alt="" />
15955    <img src="/images/logo/logo-text.png" alt="" />
15956    <img src="/images/logo/logo-text.png" alt="" />
15957    <img src="/images/logo/logo-text.png" alt="" />
15958    <img src="/images/logo/logo-text.png" alt="" />
15959  </div>
15960  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15961  <div class="top-nav">
15962    <div class="top-nav-inner">
15963      <a class="brand" href="/">
15964        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15965        <div class="brand-copy">
15966          <div class="brand-title">OxideSLOC</div>
15967          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15968        </div>
15969      </a>
15970      <div class="nav-project-slot">
15971        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
15972          <span class="nav-project-label">Project</span>
15973          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
15974        </div>
15975      </div>
15976      <div class="nav-status">
15977        <a class="nav-pill" href="/">Home</a>
15978        <div class="nav-dropdown">
15979          <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>
15980          <div class="nav-dropdown-menu">
15981            <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>
15982          </div>
15983        </div>
15984        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15985        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15986        <div class="nav-dropdown">
15987          <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>
15988          <div class="nav-dropdown-menu">
15989            <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>
15990          </div>
15991        </div>
15992        <div class="server-status-wrap" id="server-status-wrap">
15993          <div class="nav-pill server-online-pill" id="server-status-pill">
15994            <span class="status-dot" id="status-dot"></span>
15995            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
15996            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15997          </div>
15998          <div class="server-status-tip">
15999            {% if server_mode %}
16000            OxideSLOC is running in server mode — accessible on your LAN.
16001            {% else %}
16002            OxideSLOC is running locally — only accessible from this machine.
16003            {% endif %}
16004            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16005          </div>
16006        </div>
16007        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16008          <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>
16009        </button>
16010        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16011          <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>
16012          <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>
16013        </button>
16014      </div>
16015    </div>
16016  </div>
16017
16018  <div class="loading" id="loading">
16019    <div class="loading-card">
16020      <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>
16021      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
16022      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
16023      <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>
16024      <div class="lc-steps" id="lc-steps">
16025        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
16026        <div class="lc-step-arrow">›</div>
16027        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
16028        <div class="lc-step-arrow">›</div>
16029        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
16030        <div class="lc-step-arrow">›</div>
16031        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
16032      </div>
16033      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
16034      <div class="lc-metrics" id="lc-metrics">
16035        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
16036        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
16037        <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>
16038        <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>
16039      </div>
16040      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
16041      <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>
16042      <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>
16043      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
16044      <div class="lc-actions hidden" id="lc-actions">
16045        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
16046        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
16047      </div>
16048      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
16049        <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>
16050        Cancel scan
16051      </button>
16052    </div>
16053  </div>
16054
16055  <div class="page">
16056    <div class="workbench-strip">
16057      <div class="workbench-box wb-stats">
16058        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
16059          <span class="wb-stats-title">Analysis session</span>
16060        </div>
16061        <div class="ws-left">
16062          <div class="ws-stat ws-stat-analyzers">
16063            <span class="ws-label">Analyzers</span>
16064            <span class="ws-value">
16065              <span class="ws-badge">60 languages</span>
16066            </span>
16067            <div class="ws-lang-tooltip">
16068              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
16069              <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>
16070              <div class="ws-lang-grid">
16071                <span class="ws-lang-item">Assembly</span>
16072                <span class="ws-lang-item">C</span>
16073                <span class="ws-lang-item">C++</span>
16074                <span class="ws-lang-item">C#</span>
16075                <span class="ws-lang-item">Clojure</span>
16076                <span class="ws-lang-item">CSS</span>
16077                <span class="ws-lang-item">Dart</span>
16078                <span class="ws-lang-item">Dockerfile</span>
16079                <span class="ws-lang-item">Elixir</span>
16080                <span class="ws-lang-item">Erlang</span>
16081                <span class="ws-lang-item">F#</span>
16082                <span class="ws-lang-item">Go</span>
16083                <span class="ws-lang-item">Groovy</span>
16084                <span class="ws-lang-item">Haskell</span>
16085                <span class="ws-lang-item">HTML</span>
16086                <span class="ws-lang-item">Java</span>
16087                <span class="ws-lang-item">JavaScript</span>
16088                <span class="ws-lang-item">Julia</span>
16089                <span class="ws-lang-item">Kotlin</span>
16090                <span class="ws-lang-item">Lua</span>
16091                <span class="ws-lang-item">Makefile</span>
16092                <span class="ws-lang-item">Nim</span>
16093                <span class="ws-lang-item">Obj-C</span>
16094                <span class="ws-lang-item">OCaml</span>
16095                <span class="ws-lang-item">Perl</span>
16096                <span class="ws-lang-item">PHP</span>
16097                <span class="ws-lang-item">PowerShell</span>
16098                <span class="ws-lang-item">Python</span>
16099                <span class="ws-lang-item">R</span>
16100                <span class="ws-lang-item">Ruby</span>
16101                <span class="ws-lang-item">Rust</span>
16102                <span class="ws-lang-item">Scala</span>
16103                <span class="ws-lang-item">SCSS</span>
16104                <span class="ws-lang-item">Shell</span>
16105                <span class="ws-lang-item">SQL</span>
16106                <span class="ws-lang-item">Svelte</span>
16107                <span class="ws-lang-item">Swift</span>
16108                <span class="ws-lang-item">TypeScript</span>
16109                <span class="ws-lang-item">Vue</span>
16110                <span class="ws-lang-item">XML</span>
16111                <span class="ws-lang-item">Zig</span>
16112                <span class="ws-lang-item">Solidity</span>
16113                <span class="ws-lang-item">Protobuf</span>
16114                <span class="ws-lang-item">HCL</span>
16115                <span class="ws-lang-item">GraphQL</span>
16116                <span class="ws-lang-item">Ada</span>
16117                <span class="ws-lang-item">VHDL</span>
16118                <span class="ws-lang-item">Verilog</span>
16119                <span class="ws-lang-item">Tcl</span>
16120                <span class="ws-lang-item">Pascal</span>
16121                <span class="ws-lang-item">Visual Basic</span>
16122                <span class="ws-lang-item">Lisp</span>
16123                <span class="ws-lang-item">Fortran</span>
16124                <span class="ws-lang-item">Nix</span>
16125                <span class="ws-lang-item">Crystal</span>
16126                <span class="ws-lang-item">D</span>
16127                <span class="ws-lang-item">GLSL</span>
16128                <span class="ws-lang-item">CMake</span>
16129                <span class="ws-lang-item">Elm</span>
16130                <span class="ws-lang-item">Awk</span>
16131              </div>
16132            </div>
16133          </div>
16134          <div class="ws-divider"></div>
16135          <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>
16136          <div class="ws-divider"></div>
16137          <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.">
16138            <span class="ws-label">Output</span>
16139            <span class="ws-value">
16140              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
16141                <span id="ws-output-root">project/sloc</span>
16142              </button>
16143            </span>
16144          </div>
16145        </div>
16146      </div>
16147      <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.">
16148        <div class="ws-history-label">Scan history</div>
16149        <div class="ws-history-inner">
16150          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
16151            <div class="ws-mini-label">Scans</div>
16152            <div class="ws-mini-value" id="ws-scan-count">—</div>
16153          </div>
16154          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
16155            <div class="ws-mini-label">Last Scan</div>
16156            <div class="ws-mini-value" id="ws-last-scan">—</div>
16157          </div>
16158          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
16159            <div class="ws-mini-label">Branch</div>
16160            <div class="ws-mini-value" id="ws-branch">—</div>
16161          </div>
16162        </div>
16163      </div>
16164    </div>
16165
16166    <div class="layout">
16167      <aside class="side-stack">
16168        <section class="step-nav">
16169        <h3>Guided scan setup</h3>
16170        <div class="sidebar-scroll-divider"></div>
16171        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
16172          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
16173          Top of page
16174        </a>
16175        <div class="sidebar-scroll-divider"></div>
16176        <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>
16177        <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>
16178        <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>
16179        <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>
16180
16181        <div class="step-steps-divider"></div>
16182
16183        <div class="step-nav-info" id="step-nav-info">
16184          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
16185          <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>
16186        </div>
16187
16188        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
16189          <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>
16190          <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>
16191          <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>
16192        </div>
16193
16194        <div class="quick-scan-divider"></div>
16195        <div class="quick-scan-section">
16196          <div class="quick-scan-label">No customization needed?</div>
16197          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
16198            <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>
16199            Quick Scan
16200          </button>
16201          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
16202        </div>
16203
16204        <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>
16205        <div class="sidebar-scroll-divider"></div>
16206        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
16207          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
16208          Skip to bottom
16209        </a>
16210        </section>
16211
16212      </aside>
16213
16214      <section class="card">
16215        <div class="card-header">
16216          <div class="card-title-row">
16217            <div>
16218              <h1 class="card-title">Guided scan configuration</h1>
16219              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
16220            </div>
16221            <div class="wizard-progress" aria-label="Scan setup progress">
16222              <div class="wizard-progress-top">
16223                <span class="wizard-progress-label">Setup progress</span>
16224                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
16225              </div>
16226              <div class="wizard-progress-track">
16227                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
16228              </div>
16229            </div>
16230          </div>
16231        </div>
16232        <div class="card-body">
16233          <form method="post" action="/analyze" id="analyze-form">
16234            <div class="wizard-step active" data-step="1">
16235              <div class="section">
16236                <div class="section-kicker">Step 1</div>
16237                <h2>Select project and preview scope</h2>
16238                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
16239                <div class="field">
16240                  <label for="path">Project path</label>
16241                  {% if !git_repo.is_empty() %}
16242                  <div class="git-source-banner">
16243                    <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>
16244                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
16245                    <a href="/git-browser">← Back to Git Browser</a>
16246                  </div>
16247                  {% endif %}
16248                  <div class="path-scope-grid">
16249                      {% if !git_repo.is_empty() %}
16250                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
16251                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
16252                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
16253                      {% else %}
16254                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
16255                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
16256                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
16257                      {% endif %}
16258                    <div class="path-scope-sep"></div>
16259                    <div class="scope-legend-row">
16260                      <span class="scope-legend-label">Scope legend:</span>
16261                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
16262                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
16263                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
16264                    </div>
16265                  </div>
16266                  {% if git_repo.is_empty() %}
16267                  {% if server_mode %}
16268                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
16269                    ℹ️ Files are compressed and streamed — no fixed size limit.
16270                  </div>
16271                  {% endif %}
16272                  <div class="path-info-row">
16273                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
16274                      <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>
16275                      <span id="project-size-text">Project size: —</span>
16276                    </button>
16277                  </div>
16278                  {% else %}
16279                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
16280                  {% endif %}
16281                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
16282                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
16283                </div>
16284
16285                <div class="scope-preview-divider" aria-hidden="true"></div>
16286
16287                <div id="preview-panel">
16288                  <div class="preview-error">Loading preview...</div>
16289                </div>
16290              </div>
16291
16292              <div class="section" style="margin-top:14px;">
16293                <div class="preset-inline-row git-inline-row">
16294                  <div class="toggle-card" style="margin:0;">
16295                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
16296                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
16297                    <label class="checkbox">
16298                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
16299                      <div>
16300                        <span>Detect and separate git submodules</span>
16301                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
16302                      </div>
16303                    </label>
16304                  </div>
16305                  <div class="explainer-card prominent" style="margin:0;">
16306                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16307                    <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>
16308                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
16309    path = libs/core
16310    url  = https://github.com/org/core.git
16311
16312[submodule "libs/ui"]
16313    path = libs/ui
16314    url  = https://github.com/org/ui.git</div>
16315                  </div>
16316                </div>
16317              </div>
16318
16319              <div class="section">
16320                <div class="field-grid">
16321                  <div class="field">
16322                    <div class="glob-label-row">
16323                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
16324                      <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>
16325                    </div>
16326                    <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>
16327                    <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>
16328                  </div>
16329                  <div class="field">
16330                    <div class="glob-label-row">
16331                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
16332                    </div>
16333                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
16334                    <div id="quick-exclude-chips" class="quick-excl-row">
16335                      <span class="quick-excl-label">Quick add:</span>
16336                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
16337                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
16338                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
16339                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
16340                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
16341                      <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>
16342                    </div>
16343                    <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>
16344                  </div>
16345                </div>
16346                <div class="glob-guidance-grid">
16347                  <div class="glob-guidance-card">
16348                    <strong>How to read them</strong>
16349                    <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>
16350                  </div>
16351                  <div class="glob-guidance-card">
16352                    <strong>Common include examples</strong>
16353                    <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>
16354                  </div>
16355                  <div class="glob-guidance-card">
16356                    <strong>Common exclude examples</strong>
16357                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
16358                  </div>
16359                </div>
16360              </div>
16361
16362              <div class="section" style="margin-top:14px;">
16363                <div class="preset-inline-row git-inline-row">
16364                  <div class="toggle-card" style="margin:0;">
16365                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
16366                    <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>
16367                    <div class="field" style="margin:0;">
16368                      <div class="input-group compact">
16369                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
16370                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
16371                      </div>
16372                      <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>
16373                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
16374                    </div>
16375                  </div>
16376                  <div class="explainer-card prominent" style="margin:0;">
16377                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16378                    <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>
16379                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
16380lcov --capture --directory . --output-file coverage/lcov.info
16381
16382# C / C++ — llvm-cov (LCOV)
16383llvm-profdata merge -sparse default.profraw -o default.profdata
16384llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
16385
16386# C# — coverlet (Cobertura XML)
16387dotnet test --collect:"XPlat Code Coverage"
16388
16389# Python — pytest-cov (Cobertura XML)
16390pytest --cov --cov-report=xml
16391
16392# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
16393./gradlew jacocoTestReport</div>
16394                  </div>
16395                </div>
16396              </div>
16397
16398              <div class="wizard-actions">
16399                <div class="left"></div>
16400                <div class="right">
16401                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
16402                </div>
16403              </div>
16404            </div>
16405
16406            <div class="wizard-step" data-step="2">
16407              <div class="section">
16408                <div class="section-kicker">Step 2</div>
16409                <h2>Choose counting behavior</h2>
16410                <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>
16411<div class="subsection-bar">Primary line classification</div>
16412                <div class="preset-kv-row">
16413                  <div class="toggle-card mixed-line-card" style="margin:0;">
16414                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
16415                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
16416                    <select id="mixed_line_policy" name="mixed_line_policy">
16417                      <option value="code_only">Code only</option>
16418                      <option value="code_and_comment">Code and comment</option>
16419                      <option value="comment_only">Comment only</option>
16420                      <option value="separate_mixed_category">Separate mixed category</option>
16421                    </select>
16422                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
16423                  </div>
16424                  <div class="explainer-card prominent" style="margin:0;">
16425                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
16426                    <div class="explainer-body" id="mixed-policy-description"></div>
16427                    <div class="code-sample" id="mixed-policy-example"></div>
16428                  </div>
16429                </div>
16430              </div>
16431
16432              <div class="subsection-bar">Additional scan rules</div>
16433              <div class="scan-rules-grid">
16434                <div class="preset-inline-row">
16435                  <div class="toggle-card" style="margin:0;">
16436                    <div class="field-help-title">Generated files</div>
16437                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
16438                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16439                  </div>
16440                  <div class="explainer-card prominent" style="margin:0;">
16441                    <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>
16442                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
16443# Files matching codegen patterns are excluded:
16444#   *.generated.cs  *.pb.go  *.g.dart</div>
16445                  </div>
16446                </div>
16447                <div class="preset-inline-row">
16448                  <div class="toggle-card" style="margin:0;">
16449                    <div class="field-help-title">Minified files</div>
16450                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
16451                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16452                  </div>
16453                  <div class="explainer-card prominent" style="margin:0;">
16454                    <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>
16455                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
16456# Heuristic: very long lines + low whitespace ratio
16457#   jquery.min.js  bundle.min.css  → skipped</div>
16458                  </div>
16459                </div>
16460                <div class="preset-inline-row">
16461                  <div class="toggle-card" style="margin:0;">
16462                    <div class="field-help-title">Vendor directories</div>
16463                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
16464                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16465                  </div>
16466                  <div class="explainer-card prominent" style="margin:0;">
16467                    <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>
16468                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
16469# Directories named vendor/ node_modules/ third_party/
16470#   → entire subtree is excluded from totals</div>
16471                  </div>
16472                </div>
16473                <div class="preset-inline-row">
16474                  <div class="toggle-card" style="margin:0;">
16475                    <div class="field-help-title">Lockfiles and manifests</div>
16476                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
16477                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
16478                  </div>
16479                  <div class="explainer-card prominent" style="margin:0;">
16480                    <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>
16481                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
16482# Files like package-lock.json  Cargo.lock  yarn.lock
16483#   → skipped unless this is enabled</div>
16484                  </div>
16485                </div>
16486                <div class="preset-inline-row">
16487                  <div class="toggle-card" style="margin:0;">
16488                    <div class="field-help-title">Binary handling</div>
16489                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
16490                    <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>
16491                  </div>
16492                  <div class="explainer-card prominent" style="margin:0;">
16493                    <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>
16494                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
16495# Detected via long lines + low whitespace heuristic
16496#   .png  .exe  .so  → skipped silently</div>
16497                  </div>
16498                </div>
16499                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
16500                  <div class="toggle-card" style="margin:0;">
16501                    <div class="field-help-title">Python docstrings</div>
16502                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
16503                    <label class="checkbox">
16504                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
16505                      <span>Count as comment-style lines</span>
16506                    </label>
16507                  </div>
16508                  <div class="explainer-card prominent" style="margin:0;">
16509                    <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>
16510                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
16511                  </div>
16512                </div>
16513              </div>
16514              <div class="subsection-bar">IEEE 1045-1992 counting</div>
16515              <div class="scan-rules-grid">
16516                <div class="preset-inline-row">
16517                  <div class="toggle-card" style="margin:0;">
16518                    <div class="field-help-title">Continuation lines</div>
16519                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
16520                    <select name="continuation_line_policy" id="continuation_line_policy">
16521                      <option value="each_physical_line" selected>Each physical line (default)</option>
16522                      <option value="collapse_to_logical">Collapse to logical line</option>
16523                    </select>
16524                  </div>
16525                  <div class="explainer-card prominent" style="margin:0;">
16526                    <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>
16527                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
16528    ((a) &gt; (b) ? (a) : (b))
16529# each_physical_line → 2 SLOC
16530# collapse_to_logical → 1 SLOC</div>
16531                  </div>
16532                </div>
16533                <div class="preset-inline-row">
16534                  <div class="toggle-card" style="margin:0;">
16535                    <div class="field-help-title">Block-comment blanks</div>
16536                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
16537                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
16538                      <option value="count_as_comment" selected>Count as comment (default)</option>
16539                      <option value="count_as_blank">Count as blank</option>
16540                    </select>
16541                  </div>
16542                  <div class="explainer-card prominent" style="margin:0;">
16543                    <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>
16544                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
16545 * Summary line
16546 *              ← blank inside block comment
16547 * Detail line
16548 */
16549# count_as_comment → blank counts toward comments
16550# count_as_blank   → blank counts toward blanks</div>
16551                  </div>
16552                </div>
16553                <div class="preset-inline-row">
16554                  <div class="toggle-card" style="margin:0;">
16555                    <div class="field-help-title">Compiler directives</div>
16556                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
16557                    <select name="count_compiler_directives" id="count_compiler_directives">
16558                      <option value="enabled" selected>Include in code SLOC (default)</option>
16559                      <option value="disabled">Exclude from code SLOC</option>
16560                    </select>
16561                  </div>
16562                  <div class="explainer-card prominent" style="margin:0;">
16563                    <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>
16564                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
16565#define BUF 256     ← compiler directive
16566int main() { … }   ← code
16567# enabled  → 3 code SLOC
16568# disabled → 1 code SLOC + 2 directive lines</div>
16569                  </div>
16570                </div>
16571              </div>
16572
16573              <div class="subsection-bar">Code Style Analysis</div>
16574              <div class="scan-rules-grid">
16575                <div class="preset-inline-row">
16576                  <div class="toggle-card" style="margin:0;">
16577                    <div class="field-help-title">Style analysis</div>
16578                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
16579                    <select name="style_analysis_enabled" id="style_analysis_enabled">
16580                      <option value="enabled" selected>Enabled (default)</option>
16581                      <option value="disabled">Disabled — skip style scoring</option>
16582                    </select>
16583                  </div>
16584                  <div class="explainer-card prominent" style="margin:0;">
16585                    <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>
16586                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
16587# style_analysis_enabled = false  (skip, faster scan)
16588# Disabling removes the Code Style section from the report.</div>
16589                  </div>
16590                </div>
16591                <div class="preset-inline-row">
16592                  <div class="toggle-card" style="margin:0;">
16593                    <div class="field-help-title">Column-width threshold</div>
16594                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
16595                    <select name="style_col_threshold" id="style_col_threshold">
16596                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
16597                      <option value="100">100 columns (Uber Go, Google Java)</option>
16598                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
16599                    </select>
16600                  </div>
16601                  <div class="explainer-card prominent" style="margin:0;">
16602                    <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>
16603                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
16604# style_col_threshold = 100 (Uber Go, Google Java)
16605# style_col_threshold = 120 (Uber Go max, Kotlin)
16606# Files where &lt;= 5% of lines exceed the limit
16607# are counted as "N-col compliant" in the report.</div>
16608                  </div>
16609                </div>
16610                <div class="preset-inline-row">
16611                  <div class="toggle-card" style="margin:0;">
16612                    <div class="field-help-title">Score alert threshold</div>
16613                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
16614                    <select name="style_score_threshold" id="style_score_threshold">
16615                      <option value="0" selected>Off — no threshold (default)</option>
16616                      <option value="40">40% — flag poorly styled files</option>
16617                      <option value="50">50% — flag below-average files</option>
16618                      <option value="60">60% — flag below-good files</option>
16619                      <option value="70">70% — flag below-strong files</option>
16620                    </select>
16621                  </div>
16622                  <div class="explainer-card prominent" style="margin:0;">
16623                    <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>
16624                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
16625# style_score_threshold = 50  (flag files &lt; 50%)
16626# Low-scoring files get a red left-border in the
16627# per-file style breakdown table.</div>
16628                  </div>
16629                </div>
16630              </div>
16631
16632              <div class="always-tracked-tip">
16633                <div class="always-tracked-tip-icon">ℹ</div>
16634                <div class="always-tracked-tip-body">
16635                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
16636                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
16637                  <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>
16638                </div>
16639              </div>
16640
16641              <div class="subsection-bar">Advanced Metrics</div>
16642              <div class="scan-rules-grid">
16643                <div class="preset-inline-row">
16644                  <div class="toggle-card" style="margin:0;">
16645                    <div class="field-help-title">COCOMO mode</div>
16646                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
16647                    <select name="cocomo_mode" id="cocomo_mode">
16648                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
16649                      <option value="semi_detached">Semi-detached — mixed constraints</option>
16650                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
16651                    </select>
16652                  </div>
16653                  <div class="explainer-card prominent" style="margin:0;">
16654                    <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>
16655                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
16656# Semi-detached: Effort = 3.0 × KSLOC^1.12
16657# Embedded:     Effort = 3.6 × KSLOC^1.20
16658# All modes: Schedule = 2.5 × Effort^d</div>
16659                  </div>
16660                </div>
16661                <div class="preset-inline-row">
16662                  <div class="toggle-card" style="margin:0;">
16663                    <div class="field-help-title">Complexity alert</div>
16664                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
16665                    <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;" />
16666                  </div>
16667                  <div class="explainer-card prominent" style="margin:0;">
16668                    <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>
16669                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
16670# 50  = flag any file with &gt; 50 branch points
16671# 100 = flag any file with &gt; 100 branch points
16672# Files above the threshold are highlighted
16673# in the result page metric strip.</div>
16674                  </div>
16675                </div>
16676                <div class="preset-inline-row">
16677                  <div class="toggle-card" style="margin:0;">
16678                    <div class="field-help-title">Duplicate handling</div>
16679                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
16680                    <select name="exclude_duplicates" id="exclude_duplicates">
16681                      <option value="disabled" selected>Detect and report only (default)</option>
16682                      <option value="enabled">Detect and exclude from SLOC totals</option>
16683                    </select>
16684                  </div>
16685                  <div class="explainer-card prominent" style="margin:0;">
16686                    <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>
16687                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
16688# detect only   → all 3 counted in SLOC
16689# exclude dupes → 1 counted, 2 excluded
16690# Duplicate groups chip always shows the count.</div>
16691                  </div>
16692                </div>
16693                <div class="always-tracked-tip" style="margin:8px 0 0;">
16694                  <div class="always-tracked-tip-icon">ℹ</div>
16695                  <div class="always-tracked-tip-body">
16696                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
16697                    <div class="always-tracked-metrics-row">
16698                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
16699                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
16700                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
16701                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
16702                    </div>
16703                    <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>
16704                  </div>
16705                </div>
16706              </div>
16707
16708              <div class="wizard-actions">
16709                <div class="left">
16710                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
16711                </div>
16712                <div class="right">
16713                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
16714                </div>
16715              </div>
16716            </div>
16717
16718            <div class="wizard-step" data-step="3">
16719              <div class="section">
16720                <div class="section-kicker">Step 3</div>
16721                <h2>Output and report identity</h2>
16722                <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>
16723                <div class="preset-kv-row">
16724                  <div class="toggle-card" style="margin:0;">
16725                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
16726                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
16727                    <select id="scan_preset">
16728                      <option value="balanced">Balanced local scan</option>
16729                      <option value="code_focused">Code focused</option>
16730                      <option value="comment_audit">Comment audit</option>
16731                      <option value="deep_review">Deep review</option>
16732                    </select>
16733                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
16734                  </div>
16735                  <div class="explainer-card">
16736                    <div class="field-help-title">Selected scan preset</div>
16737                    <div class="explainer-body" id="scan-preset-description"></div>
16738                    <div class="preset-summary-row" id="scan-preset-summary"></div>
16739                    <div class="code-sample" id="scan-preset-example"></div>
16740                    <div class="preset-note" id="scan-preset-note"></div>
16741                  </div>
16742                </div>
16743                <hr class="step3-separator" />
16744                <div class="preset-kv-row">
16745                  <div class="toggle-card" style="margin:0;">
16746                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
16747                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
16748                    <select id="artifact_preset">
16749                      <option value="review">Review bundle</option>
16750                      <option value="full">Full bundle</option>
16751                      <option value="html_only">HTML only</option>
16752                      <option value="machine">Machine bundle</option>
16753                    </select>
16754                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
16755                  </div>
16756                  <div class="explainer-card">
16757                    <div class="field-help-title">Selected artifact preset</div>
16758                    <div class="explainer-body" id="artifact-preset-description"></div>
16759                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
16760                    <div class="code-sample" id="artifact-preset-example"></div>
16761                  </div>
16762                </div>
16763              </div>
16764
16765              <div class="section section-spacer-top">
16766                <div class="output-field-row">
16767                  <div class="field">
16768                    <label for="output_dir">Output directory</label>
16769                    {% if server_mode %}
16770                    <div class="input-group compact">
16771                      <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);" />
16772                    </div>
16773                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
16774                    {% else %}
16775                    <div class="input-group compact">
16776                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
16777                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
16778                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
16779                    </div>
16780                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
16781                    {% endif %}
16782                  </div>
16783                  <div class="output-field-aside">
16784                    <strong>Where reports land</strong>
16785                    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.
16786                  </div>
16787                </div>
16788              </div>
16789
16790              <div class="section section-spacer-top">
16791                <div class="output-field-row">
16792                  <div class="field">
16793                    <label for="report_title">Report title</label>
16794                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
16795                    <div class="hint">Appears in HTML and PDF output headers.</div>
16796                  </div>
16797                  <div class="output-field-aside">
16798                    <strong>Shown in exported artifacts</strong>
16799                    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.
16800                  </div>
16801                </div>
16802              </div>
16803
16804              <div class="section section-spacer-top">
16805                <div class="output-field-row">
16806                  <div class="field">
16807                    <label for="report_header_footer">Report header / footer</label>
16808                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
16809                    <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>
16810                  </div>
16811                  <div class="output-field-aside">
16812                    <strong>Page-level identification</strong>
16813                    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.
16814                  </div>
16815                </div>
16816              </div>
16817
16818              <div class="wizard-actions">
16819                <div class="left">
16820                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
16821                </div>
16822                <div class="right">
16823                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
16824                </div>
16825              </div>
16826            </div>
16827
16828            <div class="wizard-step" data-step="4">
16829              <div class="section">
16830                <div class="section-kicker">Step 4</div>
16831                <h2>Review selections and run</h2>
16832                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
16833                <div class="review-grid">
16834                  <div class="review-card highlight">
16835                    <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>
16836                    <ul id="review-scan-summary"></ul>
16837                  </div>
16838                  <div class="review-card highlight">
16839                    <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>
16840                    <ul id="review-count-summary"></ul>
16841                  </div>
16842                  <div class="review-card">
16843                    <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>
16844                    <ul id="review-artifact-summary"></ul>
16845                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
16846                  </div>
16847                  <div class="review-card">
16848                    <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>
16849                    <ul id="review-preview-summary"></ul>
16850                  </div>
16851                </div>
16852              </div>
16853
16854              <div class="wizard-actions">
16855                <div class="left">
16856                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
16857                </div>
16858                <div class="right">
16859                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
16860                </div>
16861              </div>
16862            </div>
16863            {% if server_mode %}
16864            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
16865            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
16866            {% endif %}
16867          </form>
16868        </div>
16869      </section>
16870    </div>
16871  </div>
16872
16873  <script nonce="{{ csp_nonce }}">
16874    (function () {
16875      function startScanPhase() {
16876        var phaseEl = document.getElementById("scan-phase");
16877        if (!phaseEl) return;
16878        var phases = [
16879          "Discovering files...",
16880          "Decoding file encodings...",
16881          "Detecting languages...",
16882          "Analyzing source lines...",
16883          "Applying counting policies...",
16884          "Aggregating results...",
16885          "Rendering report..."
16886        ];
16887        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
16888        var i = 0;
16889        function next() {
16890          phaseEl.style.opacity = "0";
16891          setTimeout(function () {
16892            phaseEl.textContent = phases[i];
16893            phaseEl.style.opacity = "0.85";
16894            var delay = durations[i] || 1800;
16895            i++;
16896            if (i < phases.length) { setTimeout(next, delay); }
16897          }, 200);
16898        }
16899        next();
16900      }
16901
16902      var form = document.getElementById("analyze-form");
16903      var loading = document.getElementById("loading");
16904      var submitButton = document.getElementById("submit-button");
16905      var pathInput = document.getElementById("path");
16906      var GIT_MODE = !!(pathInput && pathInput.readOnly);
16907      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
16908      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
16909      var outputDirInput = document.getElementById("output_dir");
16910      var reportTitleInput = document.getElementById("report_title");
16911      var previewPanel = document.getElementById("preview-panel");
16912      var refreshButton = document.getElementById("refresh-preview");
16913      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
16914      var useSamplePath = document.getElementById("use-sample-path");
16915      var useDefaultOutput = document.getElementById("use-default-output");
16916      var browsePath = document.getElementById("browse-path");
16917      var browseOutputDir = document.getElementById("browse-output-dir");
16918      var browseCoverage = document.getElementById("browse-coverage");
16919      var coverageInput = document.getElementById("coverage_file");
16920      var covScanStatus = document.getElementById("cov-scan-status");
16921      var coverageSuggestTimer = null;
16922      var covAutoFilled = false;
16923      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
16924
16925      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
16926      (function() {
16927        var ids = ["path", "output_dir"];
16928        ids.forEach(function(id) {
16929          var el = document.getElementById(id);
16930          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
16931        });
16932      }());
16933      function fmtBytes(b) {
16934        b = Number(b) || 0;
16935        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
16936        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
16937        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
16938        return b + ' B';
16939      }
16940      var themeToggle = document.getElementById("theme-toggle");
16941
16942      function showBannerToast(msg, isError, opts) {
16943        opts = opts || {};
16944        var t = document.createElement('div');
16945        t.className = isError ? 'toast-error' : 'toast-success';
16946        var topPos = opts.top ? '80px' : null;
16947        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
16948          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
16949          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
16950          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
16951        if (opts.icon) {
16952          var inner = document.createElement('span');
16953          inner.innerHTML = opts.icon + ' ';
16954          t.appendChild(inner);
16955        }
16956        t.appendChild(document.createTextNode(msg));
16957        document.body.appendChild(t);
16958        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
16959      }
16960      var mixedLinePolicy = document.getElementById("mixed_line_policy");
16961      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
16962      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
16963      var scanPreset = document.getElementById("scan_preset");
16964      var artifactPreset = document.getElementById("artifact_preset");
16965      var includeGlobsInput = document.getElementById("include_globs");
16966      var excludeGlobsInput = document.getElementById("exclude_globs");
16967
16968      // Include globs scope badge — updates reactively as the user types.
16969      (function() {
16970        var badge = document.getElementById("include-scope-badge");
16971        if (!badge || !includeGlobsInput) return;
16972        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> ';
16973        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> ';
16974        function update() {
16975          var val = includeGlobsInput.value.trim();
16976          if (!val) {
16977            badge.className = "include-scope-badge scope-all";
16978            badge.innerHTML = iconCheck + "All files eligible — no include filter active";
16979          } else {
16980            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
16981            badge.className = "include-scope-badge scope-narrow";
16982            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " — only matching files will be included";
16983          }
16984        }
16985        includeGlobsInput.addEventListener("input", update);
16986        update();
16987      }());
16988
16989      // Quick-exclude chips — append pattern to exclude_globs textarea.
16990      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
16991        chip.addEventListener("click", function() {
16992          var pattern = chip.getAttribute("data-pattern") || "";
16993          if (!pattern || !excludeGlobsInput) return;
16994          var current = excludeGlobsInput.value.trim();
16995          // For the "skip all" chip, replace any existing dep patterns cleanly.
16996          var patterns = pattern.split("\n");
16997          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
16998          var added = false;
16999          patterns.forEach(function(p) {
17000            p = p.trim();
17001            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
17002          });
17003          if (added) {
17004            excludeGlobsInput.value = lines.join("\n");
17005            excludeGlobsInput.dispatchEvent(new Event("input"));
17006          }
17007          chip.classList.add("active");
17008        });
17009      });
17010
17011      var liveReportTitle = document.getElementById("live-report-title");
17012      var navProjectPill = document.getElementById("nav-project-pill");
17013      var navProjectTitle = document.getElementById("nav-project-title");
17014      var reportTitlePreview = null;
17015      var wizardProgressFill = document.getElementById("wizard-progress-fill");
17016      var wizardProgressValue = document.getElementById("wizard-progress-value");
17017      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
17018      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
17019      var reportTitleTouched = false;
17020      var currentStep = 1;
17021      var previewTimer = null;
17022      var _previewGen = 0;
17023      var quickScanBtn = document.getElementById("quick-scan-btn");
17024
17025      function dismissAnalysisModal() {
17026        if (loading) loading.classList.remove("active");
17027        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17028          var el = document.getElementById(id);
17029          if (el) el.classList.add("hidden");
17030        });
17031        var cancelBtn = document.getElementById("lc-cancel-btn");
17032        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
17033        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
17034        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
17035        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
17036        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");}
17037        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
17038        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17039        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17040        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17041        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17042        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17043      }
17044
17045      var lcDismissBtn = document.getElementById("lc-dismiss");
17046      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
17047
17048      // When the browser restores this page from bfcache (Back button after navigating to results),
17049      // the loading overlay would still be showing its active state. Dismiss it immediately.
17050      window.addEventListener("pageshow", function(e) {
17051        if (e.persisted) { dismissAnalysisModal(); }
17052      });
17053
17054      function startAsyncAnalysis(formData) {
17055        var gitRepo = (formData.get("git_repo") || "").toString();
17056        var gitRef  = (formData.get("git_ref")  || "").toString();
17057        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
17058        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
17059
17060        var pathEl = document.getElementById("lc-path-text");
17061        if (pathEl) pathEl.textContent = displayPath;
17062
17063        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17064          var el = document.getElementById(id);
17065          if (el) el.classList.add("hidden");
17066        });
17067        var cancelBtn = document.getElementById("lc-cancel-btn");
17068        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
17069        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17070        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17071        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17072        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
17073        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
17074        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
17075        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");}
17076        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
17077
17078        if (loading) loading.classList.add("active");
17079
17080        var startTime = Date.now();
17081        var elapsedTimer = setInterval(function() {
17082          var s = Math.floor((Date.now() - startTime) / 1000);
17083          var el = document.getElementById("lc-elapsed");
17084          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
17085        }, 1000);
17086
17087        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
17088
17089        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();}
17090
17091        var PHASE_DESC = {
17092          'Starting': 'Initializing language analyzers and loading configuration…',
17093          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
17094          'Running': 'Running the lexical state machine across all discovered source files…',
17095          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
17096          'Done': 'Analysis complete — loading your results…',
17097          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
17098        };
17099        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
17100        function lcSetPhase(txt) {
17101          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
17102          var desc = document.getElementById("lc-stage-desc");
17103          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
17104          var step = PHASE_STEP[txt] || 1;
17105          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");}
17106        }
17107
17108        function lcShowCancelled() {
17109          clearInterval(elapsedTimer);
17110          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
17111          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
17112          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
17113          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
17114          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
17115          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
17116          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
17117          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
17118          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17119          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17120        }
17121
17122        var lcCancelBtn = document.getElementById("lc-cancel-btn");
17123        if (lcCancelBtn) {
17124          lcCancelBtn.onclick = function() {
17125            if (!activeWaitId) { dismissAnalysisModal(); return; }
17126            lcCancelBtn.disabled = true;
17127            lcCancelBtn.textContent = "Cancelling…";
17128            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
17129              .then(function() { lcShowCancelled(); })
17130              .catch(function() { lcShowCancelled(); });
17131          };
17132        }
17133
17134        function lcShowError(msg) {
17135          clearInterval(elapsedTimer);
17136          lcSetPhase("Failed");
17137          var msgEl = document.getElementById("lc-err-msg");
17138          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
17139          var errEl = document.getElementById("lc-err");
17140          var actEl = document.getElementById("lc-actions");
17141          if (errEl) errEl.classList.remove("hidden");
17142          if (actEl) actEl.classList.remove("hidden");
17143          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17144          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17145        }
17146
17147        function lcPoll(waitId) {
17148          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
17149            .then(function(r) {
17150              if (!r.ok) throw new Error("HTTP " + r.status);
17151              return r.json();
17152            })
17153            .then(function(data) {
17154              pollRetries = 0;
17155              if (data.state === "complete") {
17156                clearInterval(elapsedTimer);
17157                lcSetPhase("Done");
17158                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
17159              } else if (data.state === "failed") {
17160                lcShowError(data.message);
17161              } else if (data.state === "cancelled") {
17162                lcShowCancelled();
17163              } else {
17164                var s = Math.floor((Date.now() - startTime) / 1000);
17165                if (s > 90 && !warnShown) {
17166                  warnShown = true;
17167                  var w = document.getElementById("lc-warn");
17168                  if (w) w.classList.remove("hidden");
17169                }
17170                lcSetPhase(data.phase || "Running");
17171                var fd = data.files_done || 0, ft = data.files_total || 0;
17172                if (ft > 0) {
17173                  var card = document.getElementById("lc-files-card");
17174                  if (card) card.classList.remove("hidden");
17175                  var el = document.getElementById("lc-files");
17176                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
17177                  var now = Date.now();
17178                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
17179                  if (fdelta > 0 && tdelta > 0.4) {
17180                    var fps = Math.round(fdelta / tdelta);
17181                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
17182                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
17183                  }
17184                  lastFd = fd; lastFdTime = now;
17185                }
17186                setTimeout(function() { lcPoll(waitId); }, 1500);
17187              }
17188            })
17189            .catch(function() {
17190              pollRetries++;
17191              if (pollRetries >= 5) {
17192                lcShowError("Lost connection to server. Reload to check status.");
17193              } else {
17194                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
17195              }
17196            });
17197        }
17198
17199        var params = new URLSearchParams(formData);
17200        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
17201          .then(function(r) {
17202            var waitId = r.headers.get("x-wait-id");
17203            if (!waitId) { window.location.href = "/scan"; return; }
17204            activeWaitId = waitId;
17205            setTimeout(function() { lcPoll(waitId); }, 1500);
17206          })
17207          .catch(function(err) {
17208            lcShowError("Could not reach server: " + (err.message || err));
17209          });
17210      }
17211
17212      if (quickScanBtn) {
17213        quickScanBtn.addEventListener("click", function () {
17214          var pathVal = pathInput ? pathInput.value.trim() : "";
17215          if (!pathVal) {
17216            alert("Please enter or browse to a project path first.");
17217            return;
17218          }
17219          quickScanBtn.disabled = true;
17220          quickScanBtn.textContent = "Scanning...";
17221          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
17222          startAsyncAnalysis(new FormData(form));
17223        });
17224      }
17225
17226      var mixedPolicyInfo = {
17227        code_only: {
17228          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.",
17229          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'
17230        },
17231        code_and_comment: {
17232          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.",
17233          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'
17234        },
17235        comment_only: {
17236          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.",
17237          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'
17238        },
17239        separate_mixed_category: {
17240          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.",
17241          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'
17242        }
17243      };
17244
17245      var scanPresetInfo = {
17246        balanced: {
17247          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.",
17248          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
17249          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
17250          note: "Best when you want a stable local overview before making deeper adjustments.",
17251          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17252        },
17253        code_focused: {
17254          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
17255          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
17256          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
17257          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
17258          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17259        },
17260        comment_audit: {
17261          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
17262          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
17263          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
17264          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
17265          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17266        },
17267        deep_review: {
17268          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
17269          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
17270          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
17271          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
17272          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
17273        }
17274      };
17275
17276      var artifactPresetInfo = {
17277        review: {
17278          description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
17279          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
17280          example: "Ideal for a quick local review before sharing results."
17281        },
17282        full: {
17283          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
17284          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
17285          example: "Use when producing a deliverable or storing a snapshot for future comparison."
17286        },
17287        html_only: {
17288          description: "Standalone HTML report only. No PDF generation, no data files.",
17289          chips: ["HTML only"],
17290          example: "Fastest option when you only need to open the report in a browser."
17291        },
17292        machine: {
17293          description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
17294          chips: ["JSON", "CSV", "no HTML", "no PDF"],
17295          example: "Use in CI to capture metrics without generating visual reports."
17296        }
17297      };
17298
17299      function applyArtifactPreset() {
17300        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
17301        if (!info) return;
17302        var descEl = document.getElementById("artifact-preset-description");
17303        var exampleEl = document.getElementById("artifact-preset-example");
17304        if (descEl) descEl.textContent = info.description;
17305        if (exampleEl) exampleEl.textContent = info.example;
17306        renderPresetChips("artifact-preset-summary", info.chips);
17307      }
17308
17309      function applyTheme(theme) {
17310        if (theme === "dark") document.body.classList.add("dark-theme");
17311        else document.body.classList.remove("dark-theme");
17312      }
17313
17314      function loadSavedTheme() {
17315        var saved = null;
17316        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
17317        applyTheme(saved === "dark" ? "dark" : "light");
17318      }
17319
17320      function updateScrollProgress() {
17321        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
17322        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
17323        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
17324        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
17325        var step = Math.min(Math.max(currentStep, 1), 4);
17326        var base = stepBase[step];
17327        var end  = stepEnd[step];
17328
17329        var scrollFrac = 0;
17330        var activePanel = document.querySelector(".wizard-step.active");
17331        if (activePanel) {
17332          var scrollTop = window.scrollY || window.pageYOffset || 0;
17333          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
17334          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
17335          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
17336          var scrolled = scrollTop + viewH - panelTop;
17337          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
17338        }
17339
17340        var percent = Math.round(base + (end - base) * scrollFrac);
17341        percent = Math.min(end, Math.max(base, percent));
17342        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
17343        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
17344      }
17345
17346      function updateWizardProgress() {
17347        updateScrollProgress();
17348      }
17349
17350      var stepDescriptions = [
17351        "Choose a project folder, apply scope filters, and preview which files will be counted.",
17352        "Configure how mixed code-plus-comment lines and docstrings are classified.",
17353        "Pick your output formats, scan preset, and where reports are saved.",
17354        "Review all settings and launch the analysis."
17355      ];
17356
17357      function updateStepNav(step) {
17358        var infoLabel = document.getElementById("step-nav-info-label");
17359        var infoDesc  = document.getElementById("step-nav-info-desc");
17360        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
17361        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
17362      }
17363
17364      function updateSidebarSummary() {
17365        var sumPath    = document.getElementById("sum-path");
17366        var sumPreset  = document.getElementById("sum-preset");
17367        var sumOutput  = document.getElementById("sum-output");
17368        var sidebarSummary = document.getElementById("sidebar-summary");
17369        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
17370        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
17371        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
17372        if (sumPath)   sumPath.textContent   = pathVal   || "—";
17373        if (sumPreset) sumPreset.textContent = presetVal || "—";
17374        if (sumOutput) sumOutput.textContent = outputVal || "—";
17375        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
17376      }
17377
17378      function setStep(step, pushHistory) {
17379        currentStep = step;
17380        stepPanels.forEach(function (panel) {
17381          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
17382        });
17383        stepButtons.forEach(function (button) {
17384          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
17385        });
17386        var layoutEl = document.querySelector(".layout");
17387        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
17388        updateWizardProgress();
17389        updateStepNav(step);
17390        stepButtons.forEach(function(btn) {
17391          var t = Number(btn.getAttribute("data-step-target"));
17392          btn.classList.toggle("done", t < step);
17393        });
17394        updateSidebarSummary();
17395
17396        if (pushHistory !== false) {
17397          try {
17398            history.pushState({ wizardStep: step }, "", "#step" + step);
17399          } catch (e) {}
17400        }
17401
17402        window.scrollTo({ top: 0, behavior: "instant" });
17403      }
17404
17405      window.addEventListener("popstate", function (e) {
17406        if (e.state && e.state.wizardStep) {
17407          setStep(e.state.wizardStep, false);
17408        } else {
17409          var hashMatch = location.hash.match(/^#step([1-4])$/);
17410          if (hashMatch) setStep(Number(hashMatch[1]), false);
17411        }
17412      });
17413
17414      function inferTitleFromPath(value) {
17415        if (!value) return "project";
17416        var cleaned = value.replace(/[\/\\]+$/, "");
17417        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
17418        return parts.length ? parts[parts.length - 1] : value;
17419      }
17420
17421      function updateReportTitleFromPath() {
17422        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
17423        if (!reportTitleTouched) {
17424          reportTitleInput.value = inferred;
17425        }
17426        var title = reportTitleInput.value || inferred;
17427        if (liveReportTitle) liveReportTitle.textContent = title;
17428        if (reportTitlePreview) reportTitlePreview.textContent = title;
17429        document.title = "OxideSLOC | " + title;
17430
17431        var projectPath = (pathInput.value || "").trim();
17432        if (navProjectPill && navProjectTitle) {
17433          if (projectPath.length > 0) {
17434            navProjectTitle.textContent = inferred;
17435            navProjectPill.classList.add("visible");
17436          } else {
17437            navProjectTitle.textContent = "";
17438            navProjectPill.classList.remove("visible");
17439          }
17440        }
17441      }
17442
17443      function updateMixedPolicyUI() {
17444        var key = mixedLinePolicy.value || "code_only";
17445        var info = mixedPolicyInfo[key];
17446        document.getElementById("mixed-policy-description").textContent = info.description;
17447        document.getElementById("mixed-policy-example").textContent = info.example;
17448      }
17449
17450      function updatePythonDocstringUI() {
17451        var checked = !!pythonDocstrings.checked;
17452        document.getElementById("python-docstring-example").textContent = checked
17453          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
17454          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
17455        document.getElementById("python-docstring-live-help").textContent = checked
17456          ? "Enabled: docstrings contribute to comment-style totals."
17457          : "Disabled: docstrings are not counted as comment content.";
17458      }
17459
17460      function renderPresetChips(targetId, chips) {
17461        var target = document.getElementById(targetId);
17462        if (!target) return;
17463        target.innerHTML = (chips || []).map(function (chip) {
17464          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
17465        }).join('');
17466      }
17467
17468      function updatePresetDescriptions() {
17469        var scanInfo = scanPresetInfo[scanPreset.value];
17470        if (!scanInfo) return;
17471        document.getElementById("scan-preset-description").textContent = scanInfo.description;
17472        document.getElementById("scan-preset-example").textContent = scanInfo.example;
17473        document.getElementById("scan-preset-note").textContent = scanInfo.note;
17474        renderPresetChips("scan-preset-summary", scanInfo.chips);
17475      }
17476
17477      function applyScanPreset() {
17478        var info = scanPresetInfo[scanPreset.value];
17479        if (!info || !info.apply) return;
17480        mixedLinePolicy.value = info.apply.mixed;
17481        pythonDocstrings.checked = !!info.apply.docstrings;
17482        document.getElementById("generated_file_detection").value = info.apply.generated;
17483        document.getElementById("minified_file_detection").value = info.apply.minified;
17484        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
17485        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
17486        document.getElementById("binary_file_behavior").value = info.apply.binary;
17487        updateMixedPolicyUI();
17488        updatePythonDocstringUI();
17489      }
17490
17491      function updateReview() {
17492        var scanSummary = document.getElementById("review-scan-summary");
17493        var countSummary = document.getElementById("review-count-summary");
17494        var artifactSummary = document.getElementById("review-artifact-summary");
17495        var outputSummary = document.getElementById("review-output-summary");
17496        var previewSummary = document.getElementById("review-preview-summary");
17497        var readinessSummary = document.getElementById("review-readiness-summary");
17498        var includeText = document.getElementById("include_globs").value.trim();
17499        var excludeText = document.getElementById("exclude_globs").value.trim();
17500        var sidePathPreview = document.getElementById("side-path-preview");
17501        var sideOutputPreview = document.getElementById("side-output-preview");
17502        var sideTitlePreview = document.getElementById("side-title-preview");
17503
17504        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
17505        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
17506        if (sideTitlePreview) {
17507          var rt = document.getElementById("report_title");
17508          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
17509        }
17510
17511        scanSummary.innerHTML = ""
17512          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
17513          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
17514          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
17515
17516        countSummary.innerHTML = ""
17517          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
17518          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
17519          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
17520          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
17521          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
17522          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
17523          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
17524          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
17525
17526        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
17527
17528        outputSummary.innerHTML = ""
17529          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
17530          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
17531
17532        if (previewSummary) {
17533          if (GIT_MODE) {
17534            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>';
17535          } else {
17536          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
17537          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
17538          var statMap = {};
17539          statButtons.forEach(function (button) {
17540            var valueNode = button.querySelector('.scope-stat-value');
17541            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
17542          });
17543          previewSummary.innerHTML = ''
17544            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
17545            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
17546            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
17547            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
17548            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
17549            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
17550
17551          if (readinessSummary) {
17552            readinessSummary.innerHTML = ''
17553              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
17554              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
17555              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
17556          }
17557          } // end else (non-GIT_MODE)
17558        }
17559      }
17560
17561      function escapeHtml(value) {
17562        return String(value)
17563          .replace(/&/g, "&amp;")
17564          .replace(/</g, "&lt;")
17565          .replace(/>/g, "&gt;")
17566          .replace(/"/g, "&quot;")
17567          .replace(/'/g, "&#39;");
17568      }
17569
17570      function isPythonVisible() {
17571        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
17572      }
17573
17574      function syncPythonVisibility() {
17575        var html = previewPanel.textContent || "";
17576        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
17577        pythonWraps.forEach(function (node) {
17578          node.classList.toggle("hidden", !hasPython);
17579        });
17580      }
17581
17582      function attachPreviewInteractions() {
17583        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
17584        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
17585        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
17586        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
17587        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
17588        var searchInput = previewPanel.querySelector("#explorer-search");
17589        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
17590        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
17591        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
17592        var activeFilter = "all";
17593        var activeLanguage = "";
17594        var searchTerm = "";
17595        var currentSortKey = null;
17596        var currentSortOrder = "asc";
17597        var childRows = {};
17598
17599        rows.forEach(function (row) {
17600          var parentId = row.getAttribute("data-parent-id") || "";
17601          var rowId = row.getAttribute("data-row-id") || "";
17602          if (!childRows[parentId]) childRows[parentId] = [];
17603          childRows[parentId].push(rowId);
17604        });
17605
17606        function rowById(id) {
17607          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
17608        }
17609
17610        function hasCollapsedAncestor(row) {
17611          var parentId = row.getAttribute("data-parent-id");
17612          while (parentId) {
17613            var parent = rowById(parentId);
17614            if (!parent) break;
17615            if (parent.getAttribute("data-expanded") === "false") return true;
17616            parentId = parent.getAttribute("data-parent-id");
17617          }
17618          return false;
17619        }
17620
17621        function updateToggleGlyph(row) {
17622          var toggle = row.querySelector(".tree-toggle");
17623          if (!toggle) return;
17624          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
17625        }
17626
17627        function rowSortValue(row, key) {
17628          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
17629        }
17630
17631        function updateSortButtons() {
17632          sortButtons.forEach(function (button) {
17633            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
17634            var indicator = button.querySelector(".tree-sort-indicator");
17635            button.classList.toggle("active", isActive);
17636            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
17637            if (indicator) {
17638              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
17639            }
17640          });
17641        }
17642
17643        function sortSiblingRows() {
17644          if (!treeContainer) {
17645            updateSortButtons();
17646            return;
17647          }
17648
17649          var rowMap = {};
17650          var childrenMap = {};
17651          rows.forEach(function (row) {
17652            var rowId = row.getAttribute("data-row-id");
17653            var parentId = row.getAttribute("data-parent-id") || "";
17654            rowMap[rowId] = row;
17655            if (!childrenMap[parentId]) childrenMap[parentId] = [];
17656            childrenMap[parentId].push(rowId);
17657          });
17658
17659          Object.keys(childrenMap).forEach(function (parentId) {
17660            if (!parentId) return;
17661            childrenMap[parentId].sort(function (a, b) {
17662              var rowA = rowMap[a];
17663              var rowB = rowMap[b];
17664              if (!currentSortKey) {
17665                return Number(a) - Number(b);
17666              }
17667              var valueA = rowSortValue(rowA, currentSortKey);
17668              var valueB = rowSortValue(rowB, currentSortKey);
17669              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
17670              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
17671              var fallbackA = rowSortValue(rowA, "name");
17672              var fallbackB = rowSortValue(rowB, "name");
17673              if (fallbackA < fallbackB) return -1;
17674              if (fallbackA > fallbackB) return 1;
17675              return Number(a) - Number(b);
17676            });
17677          });
17678
17679          var orderedIds = [];
17680          function pushChildren(parentId) {
17681            (childrenMap[parentId] || []).forEach(function (childId) {
17682              orderedIds.push(childId);
17683              pushChildren(childId);
17684            });
17685          }
17686
17687          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
17688            orderedIds.push(topId);
17689            pushChildren(topId);
17690          });
17691
17692          orderedIds.forEach(function (id) {
17693            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
17694          });
17695          updateSortButtons();
17696        }
17697
17698        function updateLanguageButtons() {
17699          languageButtons.forEach(function (button) {
17700            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
17701            var isActive = languageValue === activeLanguage;
17702            button.classList.toggle("active", isActive);
17703          });
17704        }
17705
17706        function rowSelfMatches(row) {
17707          var kind = row.getAttribute("data-kind");
17708          var status = row.getAttribute("data-status");
17709          var language = (row.getAttribute("data-language") || "").toLowerCase();
17710          var name = row.getAttribute("data-name-lower") || "";
17711          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
17712          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
17713          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
17714          var passesLanguage = !activeLanguage || language === activeLanguage;
17715          return passesFilter && passesSearch && passesLanguage;
17716        }
17717
17718        function hasMatchingDescendant(rowId) {
17719          return (childRows[rowId] || []).some(function (childId) {
17720            var childRow = rowById(childId);
17721            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
17722          });
17723        }
17724
17725        function rowMatches(row) {
17726          if (rowSelfMatches(row)) return true;
17727          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
17728        }
17729
17730        function resetViewState() {
17731          activeFilter = "all";
17732          activeLanguage = "";
17733          searchTerm = "";
17734          currentSortKey = null;
17735          currentSortOrder = "asc";
17736          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17737          if (searchInput) searchInput.value = "";
17738          if (filterSelect) filterSelect.value = "all";
17739          updateLanguageButtons();
17740        }
17741
17742        function applyVisibility() {
17743          rows.forEach(function (row) {
17744            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
17745            row.classList.toggle("hidden-by-filter", !visible);
17746            row.style.display = visible ? "grid" : "none";
17747          });
17748          buttons.forEach(function (button) {
17749            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
17750          });
17751          if (filterSelect) filterSelect.value = activeFilter;
17752        }
17753
17754        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
17755        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
17756        var originalStats = {};
17757        buttons.forEach(function (btn) {
17758          var f = btn.getAttribute('data-filter');
17759          var v = btn.querySelector('.scope-stat-value');
17760          if (f && v) originalStats[f] = v.textContent;
17761        });
17762
17763        function applySubmoduleStats(statsJson) {
17764          try {
17765            var s = JSON.parse(statsJson);
17766            buttons.forEach(function (btn) {
17767              var f = btn.getAttribute('data-filter');
17768              var v = btn.querySelector('.scope-stat-value');
17769              if (!v) return;
17770              if (f === 'dir') v.textContent = s.dirs;
17771              else if (f === 'file') v.textContent = s.files;
17772              else if (f === 'supported') v.textContent = s.supported;
17773              else if (f === 'skipped') v.textContent = s.skipped;
17774              else if (f === 'unsupported') v.textContent = s.unsupported;
17775            });
17776          } catch (e) {}
17777        }
17778
17779        function restoreBaseRepoStats() {
17780          buttons.forEach(function (btn) {
17781            var f = btn.getAttribute('data-filter');
17782            var v = btn.querySelector('.scope-stat-value');
17783            if (v && originalStats[f]) v.textContent = originalStats[f];
17784          });
17785          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17786          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
17787        }
17788
17789        submoduleChips.forEach(function (chip) {
17790          chip.addEventListener('click', function () {
17791            var statsJson = chip.getAttribute('data-sub-stats');
17792            if (!statsJson) return;
17793            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17794            chip.classList.add('active');
17795            applySubmoduleStats(statsJson);
17796            if (baseRepoBtn) baseRepoBtn.style.display = '';
17797          });
17798        });
17799
17800        if (baseRepoBtn) {
17801          baseRepoBtn.addEventListener('click', function () {
17802            restoreBaseRepoStats();
17803            resetViewState();
17804            sortSiblingRows();
17805            applyVisibility();
17806          });
17807        }
17808
17809        buttons.forEach(function (button) {
17810          button.addEventListener("click", function () {
17811            var filterValue = button.getAttribute("data-filter") || "all";
17812            if (filterValue === "reset-view") {
17813              restoreBaseRepoStats();
17814              resetViewState();
17815              sortSiblingRows();
17816              applyVisibility();
17817              return;
17818            }
17819            activeFilter = filterValue;
17820            applyVisibility();
17821          });
17822        });
17823
17824        rows.forEach(function (row) {
17825          updateToggleGlyph(row);
17826          var toggle = row.querySelector(".tree-toggle");
17827          if (toggle) {
17828            toggle.addEventListener("click", function () {
17829              var expanded = row.getAttribute("data-expanded") !== "false";
17830              row.setAttribute("data-expanded", expanded ? "false" : "true");
17831              updateToggleGlyph(row);
17832              applyVisibility();
17833            });
17834          }
17835        });
17836
17837        actionButtons.forEach(function (button) {
17838          button.addEventListener("click", function () {
17839            var action = button.getAttribute("data-explorer-action");
17840            if (action === "expand-all") {
17841              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17842            } else if (action === "collapse-all") {
17843              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
17844            } else if (action === "clear-filters") {
17845              resetViewState();
17846            }
17847            sortSiblingRows();
17848            applyVisibility();
17849          });
17850        });
17851
17852        if (filterSelect) {
17853          filterSelect.addEventListener("change", function () {
17854            activeFilter = filterSelect.value || "all";
17855            applyVisibility();
17856          });
17857        }
17858
17859        languageButtons.forEach(function (button) {
17860          button.addEventListener("click", function () {
17861            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
17862            updateLanguageButtons();
17863            applyVisibility();
17864          });
17865        });
17866
17867        sortButtons.forEach(function (button) {
17868          button.addEventListener("click", function () {
17869            var sortKey = button.getAttribute("data-sort-key");
17870            if (currentSortKey === sortKey) {
17871              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
17872            } else {
17873              currentSortKey = sortKey;
17874              currentSortOrder = "asc";
17875            }
17876            sortSiblingRows();
17877            applyVisibility();
17878          });
17879        });
17880
17881        if (searchInput) {
17882          searchInput.addEventListener("input", function () {
17883            searchTerm = searchInput.value.trim().toLowerCase();
17884            applyVisibility();
17885          });
17886        }
17887
17888        updateLanguageButtons();
17889        sortSiblingRows();
17890        applyVisibility();
17891      }
17892
17893      function loadPreview() {
17894        if (!previewPanel || !pathInput) return;
17895        if (GIT_MODE) {
17896          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>';
17897          return;
17898        }
17899        var path = pathInput.value.trim();
17900        var zeroWarn = document.getElementById('zero-files-warning');
17901        if (!path) {
17902          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
17903          if (zeroWarn) zeroWarn.style.display = 'none';
17904          return;
17905        }
17906        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
17907        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
17908        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
17909        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
17910        var myGen = ++_previewGen;
17911        var _prevMsgs = [
17912          'Scanning directory structure…',
17913          'Detecting file types…',
17914          'Applying include / exclude filters…',
17915          'Estimating file counts…',
17916          'Building scope preview…',
17917          'Almost there…'
17918        ];
17919        var _prevMsgIdx = 0;
17920        var _prevStart = Date.now();
17921        previewPanel.innerHTML =
17922          '<div class="preview-loading">' +
17923          '<div class="preview-spinner"></div>' +
17924          '<div class="preview-loading-text">' +
17925          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
17926          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
17927          '</div></div>';
17928        var _sizeTextEl = document.getElementById('project-size-text');
17929        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
17930        window._previewInterval = setInterval(function() {
17931          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
17932          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
17933          var ml = document.getElementById('plm');
17934          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
17935        }, 1500);
17936        window._previewElapsedTimer = setInterval(function() {
17937          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
17938          var el = document.getElementById('ple');
17939          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
17940        }, 1000);
17941        var previewUrl = "/preview?path=" + encodeURIComponent(path)
17942          + "&include_globs=" + encodeURIComponent(includeValue)
17943          + "&exclude_globs=" + encodeURIComponent(excludeValue);
17944        fetch(previewUrl)
17945          .then(function (response) { return response.text(); })
17946          .then(function (html) {
17947            if (myGen !== _previewGen) return;
17948            clearInterval(window._previewInterval); window._previewInterval = null;
17949            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17950            previewPanel.innerHTML = html;
17951            attachPreviewInteractions();
17952            syncPythonVisibility();
17953            updateReview();
17954            setTimeout(collapseLanguagePills, 50);
17955            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
17956            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
17957            var sizeText = document.getElementById('project-size-text');
17958            var sizeBtn = document.getElementById('project-size-btn');
17959            // In server mode with upload sizes available, keep the compressed/original pair.
17960            if (SERVER_MODE && window._lastUploadSizes) {
17961              var us = window._lastUploadSizes;
17962              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
17963                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
17964              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
17965                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
17966            } else if (sizeText && projectSize) {
17967              sizeText.textContent = 'Project size: ' + projectSize;
17968              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
17969            } else if (sizeText) {
17970              sizeText.textContent = 'Project size: —';
17971            }
17972            if (zeroWarn) {
17973              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
17974              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
17975              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
17976              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
17977              if (supportedCount === 0 && fileCount > 0) {
17978                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).';
17979                zeroWarn.style.display = '';
17980              } else {
17981                zeroWarn.style.display = 'none';
17982              }
17983            }
17984          })
17985          .catch(function (err) {
17986            if (myGen !== _previewGen) return;
17987            clearInterval(window._previewInterval); window._previewInterval = null;
17988            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17989            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
17990          });
17991      }
17992
17993      function pickDirectory(targetInput, kind) {
17994        if (!targetInput) {
17995          showBannerToast("Directory picker: input element not found.", true);
17996          return;
17997        }
17998        if (SERVER_MODE) {
17999          if (kind === 'output') {
18000            showBannerToast(
18001              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
18002              false,
18003              { top: true, icon: '📁' }
18004            );
18005            return;
18006          }
18007          var inputEl = kind === 'coverage'
18008            ? document.getElementById('cov-upload-input')
18009            : document.getElementById('dir-upload-input');
18010          if (!inputEl) return;
18011          inputEl.onchange = function () {
18012            var files = inputEl.files;
18013            if (!files || files.length === 0) return;
18014            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
18015            if (browseBtn) browseBtn.disabled = true;
18016
18017            function fileToBase64(file) {
18018              return new Promise(function (resolve, reject) {
18019                var reader = new FileReader();
18020                reader.onload = function () {
18021                  var b64 = reader.result.split(',')[1];
18022                  resolve(b64);
18023                };
18024                reader.onerror = reject;
18025                reader.readAsDataURL(file);
18026              });
18027            }
18028
18029            if (kind === 'coverage') {
18030              var f = files[0];
18031              if (previewPanel && targetInput === pathInput)
18032                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
18033              fileToBase64(f).then(function (b64) {
18034                return fetch('/api/upload-file', {
18035                  method: 'POST',
18036                  headers: { 'Content-Type': 'application/json' },
18037                  body: JSON.stringify({ filename: f.name, content: b64 })
18038                }).then(function (r) { return r.json(); });
18039              })
18040                .then(function (d) {
18041                  if (d && d.tmp_path) {
18042                    if (coverageInput) coverageInput.value = d.tmp_path;
18043                    setCovStatus('idle');
18044                  } else if (d && d.error) { showBannerToast(d.error, true); }
18045                })
18046                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
18047                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
18048            } else {
18049              // ── Filter to source-code files only ─────────────────────────
18050              // Binary, generated, and dependency files (node_modules, .git,
18051              // build artifacts) are skipped so they are never uploaded.
18052              var CODE_EXTS = new Set([
18053                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18054                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18055                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18056                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18057                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18058                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
18059                'tf','hcl','proto','thrift','avsc','graphql','gql'
18060              ]);
18061              var codeFiles = [];
18062              for (var i = 0; i < files.length; i++) {
18063                var f = files[i];
18064                var name = f.name;
18065                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
18066                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
18067                  codeFiles.push(f); continue;
18068                }
18069                var dot = name.lastIndexOf('.');
18070                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
18071              }
18072              // Collect specific .git metadata files for server-side git detection.
18073              // These have no source extension so they are excluded by the loop above,
18074              // but the server needs them to read branch/commit/author without running git.
18075              var gitMetaFiles = [];
18076              for (var i = 0; i < files.length; i++) {
18077                var f = files[i];
18078                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
18079                var gitIdx = rp.indexOf('/.git/');
18080                if (gitIdx < 0) continue;
18081                var gitRel = rp.slice(gitIdx + 1);
18082                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
18083                    gitRel === '.git/logs/HEAD' ||
18084                    gitRel.startsWith('.git/refs/heads/') ||
18085                    gitRel.startsWith('.git/refs/tags/')) {
18086                  gitMetaFiles.push(f);
18087                }
18088              }
18089              var uploadFiles = codeFiles.concat(gitMetaFiles);
18090              var total = files.length;
18091              var kept = codeFiles.length;
18092              if (kept === 0) {
18093                if (previewPanel && targetInput === pathInput)
18094                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
18095                if (browseBtn) browseBtn.disabled = false;
18096                inputEl.value = '';
18097                return;
18098              }
18099
18100              // ── Helper: apply upload result to UI ────────────────────────
18101              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
18102              function applyUploadResult(tmpPath, sizes) {
18103                targetInput.value = tmpPath;
18104                scrollInputToEnd(targetInput);
18105                if (sizes && SERVER_MODE) {
18106                  window._lastUploadSizes = sizes;
18107                  // Immediately show both sizes before preview loads.
18108                  var sizeText = document.getElementById('project-size-text');
18109                  var sizeBtn = document.getElementById('project-size-btn');
18110                  if (sizeText) {
18111                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18112                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18113                  }
18114                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18115                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18116                }
18117                if (targetInput === pathInput) {
18118                  updateReportTitleFromPath();
18119                  autoSetOutputDir(tmpPath);
18120                  fetchProjectHistory(tmpPath);
18121                  loadPreview();
18122                  suggestCoverageFile(tmpPath);
18123                }
18124                updateReview();
18125                if (browseBtn) browseBtn.disabled = false;
18126                inputEl.value = '';
18127              }
18128
18129              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
18130              if (typeof CompressionStream !== 'undefined') {
18131                if (previewPanel && targetInput === pathInput)
18132                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18133
18134                // Build a minimal POSIX ustar tar header for a single file entry.
18135                function buildUstarHeader(filePath, fileSize) {
18136                  var BLOCK = 512;
18137                  var hdr = new Uint8Array(BLOCK);
18138                  var enc = new TextEncoder();
18139                  function wStr(off, len, s) {
18140                    var b = enc.encode(s);
18141                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
18142                  }
18143                  function wOct(off, len, val) {
18144                    var s = val.toString(8);
18145                    while (s.length < len - 1) s = '0' + s;
18146                    wStr(off, len, s + '\0');
18147                  }
18148                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
18149                  var name = filePath, prefix = '';
18150                  if (filePath.length > 99) {
18151                    var split = filePath.lastIndexOf('/', 154);
18152                    if (split > 0 && filePath.length - split - 1 <= 99) {
18153                      prefix = filePath.substring(0, split);
18154                      name   = filePath.substring(split + 1);
18155                    } else { name = filePath.substring(0, 99); }
18156                  }
18157                  wStr(0,   100, name);          // name
18158                  wOct(100,   8, 0o000644);      // mode
18159                  wOct(108,   8, 0);             // uid
18160                  wOct(116,   8, 0);             // gid
18161                  wOct(124,  12, fileSize);      // size
18162                  wOct(136,  12, 0);             // mtime (epoch)
18163                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
18164                  hdr[156] = 48;                 // type flag '0' = regular file
18165                  wStr(157, 100, '');            // linkname
18166                  wStr(257,   6, 'ustar');       // magic
18167                  wStr(263,   2, '00');          // version
18168                  wStr(265,  32, '');            // uname
18169                  wStr(297,  32, '');            // gname
18170                  wOct(329,   8, 0);             // devmajor
18171                  wOct(337,   8, 0);             // devminor
18172                  wStr(345, 155, prefix);        // prefix
18173                  // Compute checksum (sum of all bytes, placeholder = 32).
18174                  var chk = 0;
18175                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18176                  var cs = chk.toString(8);
18177                  while (cs.length < 6) cs = '0' + cs;
18178                  wStr(148, 8, cs + '\0 ');
18179                  return hdr;
18180                }
18181
18182                // Build tar.gz one file at a time, piping through CompressionStream.
18183                // RAM usage = compressed output buffer + one file at a time.
18184                (async function () {
18185                  try {
18186                    var BLOCK = 512;
18187                    var cs     = new CompressionStream('gzip');
18188                    var writer = cs.writable.getWriter();
18189                    var chunks = [];
18190                    var reader = cs.readable.getReader();
18191                    var collecting = (async function () {
18192                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
18193                    })();
18194
18195                    for (var i = 0; i < uploadFiles.length; i++) {
18196                      var file = uploadFiles[i];
18197                      var path = file.webkitRelativePath || file.name;
18198                      var buf  = await file.arrayBuffer();
18199                      var data = new Uint8Array(buf);
18200                      // Header block
18201                      await writer.write(buildUstarHeader(path, data.length));
18202                      // Data padded to 512-byte boundary
18203                      if (data.length > 0) {
18204                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
18205                        var block  = new Uint8Array(padded);
18206                        block.set(data);
18207                        await writer.write(block);
18208                      }
18209                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
18210                        if (previewPanel && targetInput === pathInput)
18211                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18212                      }
18213                    }
18214                    // End-of-archive: two 512-byte zero blocks
18215                    await writer.write(new Uint8Array(BLOCK * 2));
18216                    await writer.close();
18217                    await collecting;
18218
18219                    var blob = new Blob(chunks, { type: 'application/gzip' });
18220                    var sizeMB = (blob.size / 1048576).toFixed(1);
18221                    if (previewPanel && targetInput === pathInput)
18222                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
18223
18224                    var resp = await fetch('/api/upload-tarball', {
18225                      method: 'POST',
18226                      headers: { 'Content-Type': 'application/gzip' },
18227                      body: blob
18228                    });
18229                    var d = await resp.json();
18230                    if (d && d.tmp_path) {
18231                      applyUploadResult(d.tmp_path, {
18232                        compressed_bytes: d.compressed_bytes || 0,
18233                        original_bytes: d.original_bytes || 0
18234                      });
18235                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18236                  } catch (e) {
18237                    showBannerToast('Upload failed: ' + String(e), true);
18238                    if (browseBtn) browseBtn.disabled = false;
18239                    inputEl.value = '';
18240                  }
18241                })();
18242
18243              } else {
18244                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
18245                // Used only on browsers that lack CompressionStream (pre-2023).
18246                var BATCH = 200;
18247                var batches = [];
18248                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
18249                var totalBatches = batches.length;
18250                if (previewPanel && targetInput === pathInput)
18251                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
18252
18253                function sendBatch(idx, currentUploadId, lastTmpPath) {
18254                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
18255                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
18256                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
18257                  Promise.all(batches[idx].map(function (file) {
18258                    return fileToBase64(file).then(function (b64) {
18259                      return { path: file.webkitRelativePath || file.name, content: b64 };
18260                    });
18261                  })).then(function (fileList) {
18262                    var body = { files: fileList };
18263                    if (currentUploadId) body.upload_id = currentUploadId;
18264                    return fetch('/api/upload-directory', {
18265                      method: 'POST', headers: { 'Content-Type': 'application/json' },
18266                      body: JSON.stringify(body)
18267                    }).then(function (r) { return r.json(); });
18268                  }).then(function (d) {
18269                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
18270                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18271                  }).catch(function (e) {
18272                    showBannerToast('Upload failed: ' + String(e), true);
18273                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
18274                  });
18275                }
18276                sendBatch(0, null, '');
18277              }
18278            }
18279          };
18280          inputEl.click();
18281          return;
18282        }
18283
18284        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
18285        if (browseButton) browseButton.disabled = true;
18286
18287        if (previewPanel && targetInput === pathInput) {
18288          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
18289        }
18290
18291        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
18292          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
18293          .then(function (data) {
18294            if (data && data.selected_path) {
18295              targetInput.value = data.selected_path;
18296              scrollInputToEnd(targetInput);
18297
18298              if (targetInput === pathInput) {
18299                updateReportTitleFromPath();
18300                autoSetOutputDir(data.selected_path);
18301                fetchProjectHistory(data.selected_path);
18302                loadPreview();
18303                suggestCoverageFile(data.selected_path);
18304              }
18305
18306              updateReview();
18307            } else if (targetInput === pathInput) {
18308              loadPreview();
18309            }
18310          })
18311          .catch(function () {
18312            window.alert("Directory picker request failed.");
18313            if (previewPanel && targetInput === pathInput) {
18314              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
18315            }
18316          })
18317          .finally(function () {
18318            if (browseButton) browseButton.disabled = false;
18319          });
18320      }
18321
18322      if (themeToggle) {
18323        themeToggle.addEventListener("click", function () {
18324          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
18325          applyTheme(nextTheme);
18326          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
18327        });
18328      }
18329
18330      stepButtons.forEach(function (button) {
18331        button.addEventListener("click", function () {
18332          setStep(Number(button.getAttribute("data-step-target")));
18333        });
18334      });
18335
18336      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
18337        button.addEventListener("click", function () {
18338          setStep(Number(button.getAttribute("data-step-target")) || 1);
18339        });
18340      });
18341
18342      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
18343        button.addEventListener("click", function () {
18344          updateReview();
18345          setStep(Number(button.getAttribute("data-next")));
18346        });
18347      });
18348
18349      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
18350        button.addEventListener("click", function () {
18351          setStep(Number(button.getAttribute("data-prev")));
18352        });
18353      });
18354
18355      document.addEventListener("keydown", function (e) {
18356        var tag = (document.activeElement || {}).tagName || "";
18357        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
18358        if (e.altKey || e.ctrlKey || e.metaKey) return;
18359        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
18360        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
18361      });
18362
18363      if (useSamplePath) {
18364        useSamplePath.addEventListener("click", function () {
18365          pathInput.value = "tests/fixtures/basic";
18366          updateReportTitleFromPath();
18367          autoSetOutputDir("tests/fixtures/basic");
18368          loadPreview();
18369          suggestCoverageFile("tests/fixtures/basic");
18370        });
18371      }
18372
18373      if (useDefaultOutput) {
18374        useDefaultOutput.addEventListener("click", function () {
18375          delete outputDirInput.dataset.userEdited;
18376          autoSetOutputDir(pathInput ? pathInput.value : "");
18377          updateReview();
18378        });
18379      }
18380
18381      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
18382      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
18383
18384      // ── Drag-and-drop directory upload (server mode only) ─────────────────
18385      // Dropping a folder onto the path field bypasses Chrome's
18386      // "Upload X files to this site?" confirmation dialog.
18387      async function readDirRecursively(dirEntry, basePath) {
18388        var reader = dirEntry.createReader();
18389        var all = [];
18390        for (;;) {
18391          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
18392          if (!batch.length) break;
18393          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
18394        }
18395        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
18396        var out = [];
18397        for (var i = 0; i < all.length; i++) {
18398          var sub = all[i];
18399          if (sub.isFile) {
18400            var f = await new Promise(function(res) { sub.file(res); });
18401            out.push({ file: f, path: basePath + '/' + sub.name });
18402          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
18403            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
18404            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
18405          }
18406        }
18407        return out;
18408      }
18409
18410      function setupPathDropZone() {
18411        if (!SERVER_MODE || !pathInput) return;
18412        var CODE_EXTS = new Set([
18413          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18414          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18415          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18416          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18417          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18418          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
18419        ]);
18420        pathInput.addEventListener('dragover', function(e) {
18421          e.preventDefault();
18422          pathInput.classList.add('drag-over');
18423        });
18424        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
18425        pathInput.addEventListener('drop', function(e) {
18426          e.preventDefault();
18427          pathInput.classList.remove('drag-over');
18428          var items = e.dataTransfer.items;
18429          if (!items || !items.length) return;
18430          var dirEntry = null;
18431          for (var i = 0; i < items.length; i++) {
18432            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
18433            if (entry && entry.isDirectory) { dirEntry = entry; break; }
18434          }
18435          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
18436          var btn = browsePath;
18437          if (btn) btn.disabled = true;
18438          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
18439
18440          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
18441            var total = allEntries.length;
18442            var codeEntries = allEntries.filter(function(e) {
18443              var n = e.file.name;
18444              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
18445              var dot = n.lastIndexOf('.');
18446              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
18447            });
18448            var kept = codeEntries.length;
18449            if (kept === 0) {
18450              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
18451              if (btn) btn.disabled = false; return;
18452            }
18453
18454            function finish(tmpPath, sizes) {
18455              pathInput.value = tmpPath;
18456              scrollInputToEnd(pathInput);
18457              if (sizes) {
18458                window._lastUploadSizes = sizes;
18459                var sizeText = document.getElementById('project-size-text');
18460                var sizeBtn = document.getElementById('project-size-btn');
18461                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18462                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18463                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18464                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18465              }
18466              updateReportTitleFromPath();
18467              autoSetOutputDir(tmpPath);
18468              fetchProjectHistory(tmpPath);
18469              loadPreview();
18470              suggestCoverageFile(tmpPath);
18471              updateReview();
18472              if (btn) btn.disabled = false;
18473            }
18474
18475            if (typeof CompressionStream === 'undefined') {
18476              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
18477              if (btn) btn.disabled = false; return;
18478            }
18479
18480            try {
18481              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18482              var BLOCK = 512;
18483              var cs = new CompressionStream('gzip');
18484              var wtr = cs.writable.getWriter();
18485              var chunks = [];
18486              var rdr = cs.readable.getReader();
18487              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
18488
18489              function buildHdr(fp, sz) {
18490                var hdr = new Uint8Array(BLOCK);
18491                var enc = new TextEncoder();
18492                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]; }
18493                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
18494                var nm = fp, pfx = '';
18495                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); } }
18496                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
18497                for (var i = 148; i < 156; i++) hdr[i] = 32;
18498                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);
18499                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18500                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
18501                return hdr;
18502              }
18503
18504              for (var i = 0; i < codeEntries.length; i++) {
18505                var ce = codeEntries[i];
18506                var buf = await ce.file.arrayBuffer();
18507                var data = new Uint8Array(buf);
18508                await wtr.write(buildHdr(ce.path, data.length));
18509                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
18510                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
18511                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18512              }
18513              await wtr.write(new Uint8Array(BLOCK * 2));
18514              await wtr.close();
18515              await collecting;
18516
18517              var blob = new Blob(chunks, { type: 'application/gzip' });
18518              var sizeMB = (blob.size / 1048576).toFixed(1);
18519              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
18520              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
18521              var d = await resp.json();
18522              if (d && d.tmp_path) {
18523                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
18524              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
18525            } catch (err) {
18526              showBannerToast('Upload failed: ' + String(err), true);
18527              if (btn) btn.disabled = false;
18528            }
18529          }).catch(function(err) {
18530            showBannerToast('Could not read folder: ' + String(err), true);
18531            if (btn) btn.disabled = false;
18532          });
18533        });
18534      }
18535      setupPathDropZone();
18536      if (browseCoverage) {
18537        browseCoverage.addEventListener("click", function () {
18538          pickDirectory(coverageInput || pathInput, "coverage");
18539        });
18540      }
18541
18542      function setCovStatus(state, opts) {
18543        if (!covScanStatus) return;
18544        opts = opts || {};
18545        covScanStatus.className = "cov-scan-status cov-scan-" + state;
18546        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
18547        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>';
18548        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>';
18549        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>';
18550        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>';
18551        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
18552        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
18553        if (state === "scanning") {
18554          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
18555        } else if (state === "found") {
18556          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18557          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
18558          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
18559          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
18560        } else if (state === "hint") {
18561          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18562          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
18563          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>';
18564        } else if (state === "none") {
18565          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
18566          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
18567        }
18568        html += '</div></div>';
18569        covScanStatus.innerHTML = html;
18570        if (state === "found") {
18571          var useBtn = covScanStatus.querySelector(".cov-scan-use");
18572          if (useBtn) useBtn.addEventListener("click", function () {
18573            if (coverageInput) coverageInput.value = "";
18574            covAutoFilled = false;
18575            setCovStatus("idle");
18576          });
18577        }
18578      }
18579
18580      function suggestCoverageFile(projectPath) {
18581        if (!coverageInput || !covScanStatus) return;
18582        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18583        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
18584        clearTimeout(coverageSuggestTimer);
18585        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
18586        setCovStatus("scanning");
18587        coverageSuggestTimer = setTimeout(function () {
18588          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
18589            .then(function (r) { return r.json(); })
18590            .then(function (d) {
18591              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18592              if (!d) { setCovStatus("none"); return; }
18593              if (d.found) {
18594                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
18595                setCovStatus("found", { found: d.found, tool: d.tool });
18596              } else if (d.tool && d.hint) {
18597                setCovStatus("hint", { tool: d.tool, hint: d.hint });
18598              } else {
18599                setCovStatus("none");
18600              }
18601            })
18602            .catch(function () { setCovStatus("idle"); });
18603        }, 600);
18604      }
18605
18606      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
18607
18608      if (coverageInput) coverageInput.addEventListener("input", function () {
18609        covAutoFilled = false;
18610        if (!this.value.trim()) setCovStatus("idle");
18611      });
18612
18613      // ── Language pill overflow: collapse to "+N more" chip ─────────────
18614      function collapseLanguagePills() {
18615        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
18616        rows.forEach(function(row) {
18617          // Remove any previous overflow chip
18618          var prev = row.querySelector('.lang-overflow-chip');
18619          if (prev) prev.remove();
18620          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
18621          pills.forEach(function(p) { p.style.display = ''; });
18622          if (!pills.length) return;
18623
18624          // Measure after restoring all pills
18625          var containerRight = row.getBoundingClientRect().right;
18626          var hidden = [];
18627          for (var i = pills.length - 1; i >= 1; i--) {
18628            var rect = pills[i].getBoundingClientRect();
18629            if (rect.right > containerRight + 2) {
18630              hidden.unshift(pills[i]);
18631              pills[i].style.display = 'none';
18632            } else {
18633              break;
18634            }
18635          }
18636
18637          if (hidden.length) {
18638            var chip = document.createElement('button');
18639            chip.type = 'button';
18640            chip.className = 'language-pill lang-overflow-chip';
18641            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
18642            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
18643            row.appendChild(chip);
18644          }
18645        });
18646      }
18647
18648      // Run after preview loads (preview panel populates language pills)
18649      var _origLoadPreviewCb = window.__previewLoaded;
18650      document.addEventListener('previewLoaded', collapseLanguagePills);
18651      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
18652      setTimeout(collapseLanguagePills, 400);
18653
18654      // ── Project history & output dir auto-set ──────────────────────────
18655      var wsOutputRoot   = document.getElementById("ws-output-root");
18656      var wsScanCount    = document.getElementById("ws-scan-count");
18657      var wsLastScan     = document.getElementById("ws-last-scan");
18658      var historyBadge   = document.getElementById("path-history-badge");
18659      var historyTimer   = null;
18660
18661      var wsOutputLink = document.getElementById("ws-output-link");
18662      function syncStripOutputRoot() {
18663        var val = outputDirInput ? outputDirInput.value : "";
18664        var display = val || "project/sloc";
18665        if (wsOutputRoot) wsOutputRoot.textContent = display;
18666        if (wsOutputLink) wsOutputLink.dataset.folder = val;
18667      }
18668
18669      function scrollInputToEnd(input) {
18670        if (!input) return;
18671        // Defer so the DOM has the new value before we measure scroll width.
18672        requestAnimationFrame(function () {
18673          input.scrollLeft = input.scrollWidth;
18674          input.selectionStart = input.selectionEnd = input.value.length;
18675        });
18676      }
18677
18678      function autoSetOutputDir(projectPath) {
18679        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
18680        if (GIT_MODE && GIT_OUTPUT_DIR) {
18681          outputDirInput.value = GIT_OUTPUT_DIR;
18682          scrollInputToEnd(outputDirInput);
18683          syncStripOutputRoot();
18684          updateReview();
18685          return;
18686        }
18687        if (!projectPath || !projectPath.trim()) return;
18688        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
18689        outputDirInput.value = cleaned + "/sloc";
18690        scrollInputToEnd(outputDirInput);
18691        syncStripOutputRoot();
18692        updateReview();
18693      }
18694
18695      var wsBranch = document.getElementById("ws-branch");
18696
18697      function fetchProjectHistory(projectPath) {
18698        if (!projectPath || !projectPath.trim()) {
18699          if (wsScanCount) wsScanCount.textContent = "—";
18700          if (wsLastScan)  wsLastScan.textContent  = "—";
18701          if (wsBranch)    wsBranch.textContent    = "—";
18702          if (historyBadge) historyBadge.style.display = "none";
18703          return;
18704        }
18705        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
18706          .then(function (r) { return r.ok ? r.json() : null; })
18707          .then(function (data) {
18708            if (!data) return;
18709            var countStr = data.scan_count > 0
18710              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
18711              : "never";
18712            var tsStr = data.last_scan_timestamp
18713              ? data.last_scan_timestamp.replace(" UTC","")
18714              : "—";
18715            if (wsScanCount) wsScanCount.textContent = countStr;
18716            if (wsLastScan)  wsLastScan.textContent  = tsStr;
18717            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
18718            if (data.scan_count > 0) {
18719              if (historyBadge) {
18720                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
18721                historyBadge.textContent = data.scan_count + " previous scan" +
18722                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
18723                  "Last: " + (data.last_scan_timestamp || "—") +
18724                  " — " + (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.";
18725                historyBadge.className = "path-history-badge found";
18726                historyBadge.style.display = "";
18727              }
18728            } else {
18729              if (historyBadge) historyBadge.style.display = "none";
18730            }
18731          })
18732          .catch(function () {});
18733      }
18734
18735      function onPathChange() {
18736        var val = pathInput ? pathInput.value : "";
18737        // Discard stale upload sizes when the user edits the path manually.
18738        window._lastUploadSizes = null;
18739        updateReportTitleFromPath();
18740        autoSetOutputDir(val);
18741        updateSidebarSummary();
18742        clearTimeout(historyTimer);
18743        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
18744        if (previewTimer) clearTimeout(previewTimer);
18745        previewTimer = setTimeout(loadPreview, 280);
18746        suggestCoverageFile(val);
18747      }
18748
18749      if (pathInput) {
18750        pathInput.addEventListener("input", onPathChange);
18751      }
18752
18753      if (outputDirInput) {
18754        outputDirInput.addEventListener("input", function () {
18755          outputDirInput.dataset.userEdited = "1";
18756          syncStripOutputRoot();
18757          updateReview();
18758        });
18759      }
18760
18761      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
18762        if (!node) return;
18763        node.addEventListener("input", function () {
18764          updateReview();
18765          if (previewTimer) clearTimeout(previewTimer);
18766          previewTimer = setTimeout(loadPreview, 280);
18767        });
18768      });
18769
18770      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
18771        var node = document.getElementById(id);
18772        if (node) node.addEventListener("change", updateReview);
18773      });
18774
18775      if (reportTitleInput) {
18776        reportTitleInput.addEventListener("input", function () {
18777          reportTitleTouched = reportTitleInput.value.trim().length > 0;
18778          updateReportTitleFromPath();
18779          updateReview();
18780        });
18781      }
18782
18783      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
18784      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
18785      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
18786      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
18787
18788      if (coverageInput) {
18789        coverageInput.addEventListener("input", function () {
18790          if (coverageInput.value.trim()) setCovStatus("idle");
18791        });
18792      }
18793
18794      if (form && loading && submitButton) {
18795        form.addEventListener("submit", function (e) {
18796          e.preventDefault();
18797          submitButton.disabled = true;
18798          submitButton.textContent = "Scanning...";
18799          startAsyncAnalysis(new FormData(form));
18800        });
18801      }
18802
18803      function openPath(folder) {
18804        if (!folder) return;
18805        fetch('/open-path?path=' + encodeURIComponent(folder))
18806          .then(function (r) { return r.json(); })
18807          .then(function (d) {
18808            if (d && d.server_mode_disabled)
18809              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18810          })
18811          .catch(function () {});
18812      }
18813
18814      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18815        btn.addEventListener('click', function () {
18816          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
18817        });
18818      });
18819
18820      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
18821      if (wsOutputLink) {
18822        wsOutputLink.addEventListener('click', function () {
18823          openPath(wsOutputLink.dataset.folder || '');
18824        });
18825      }
18826
18827      loadSavedTheme();
18828      updateMixedPolicyUI();
18829      updatePythonDocstringUI();
18830      applyScanPreset();
18831      updatePresetDescriptions();
18832      applyArtifactPreset();
18833      updateReview();
18834      updateScrollProgress(); // initialise bar to 0% (step 1)
18835      window.addEventListener("scroll", updateScrollProgress, { passive: true });
18836      onPathChange();         // seed output dir, history badge, and preview from initial path
18837      updateStepNav(1);
18838
18839      // Restore step from URL hash on initial load (e.g., back-forward cache)
18840      (function() {
18841        var hashMatch = location.hash.match(/^#step([1-4])$/);
18842        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
18843      })();
18844
18845      (function randomizeWatermarks() {
18846        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
18847        if (!wms.length) return;
18848        var placed = [];
18849        function tooClose(top, left) {
18850          for (var i = 0; i < placed.length; i++) {
18851            var dt = Math.abs(placed[i][0] - top);
18852            var dl = Math.abs(placed[i][1] - left);
18853            if (dt < 16 && dl < 12) return true;
18854          }
18855          return false;
18856        }
18857        function pick(leftBand) {
18858          for (var attempt = 0; attempt < 50; attempt++) {
18859            var top = Math.random() * 88 + 2;
18860            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18861            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18862          }
18863          var top = Math.random() * 88 + 2;
18864          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18865          placed.push([top, left]);
18866          return [top, left];
18867        }
18868        var half = Math.floor(wms.length / 2);
18869        wms.forEach(function (img, i) {
18870          var pos = pick(i < half);
18871          var size = Math.floor(Math.random() * 80 + 110);
18872          var rot = (Math.random() * 360).toFixed(1);
18873          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
18874          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;
18875        });
18876      })();
18877
18878      (function spawnCodeParticles() {
18879        var container = document.getElementById('code-particles');
18880        if (!container) return;
18881        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'];
18882        for (var i = 0; i < 38; i++) {
18883          (function(idx) {
18884            var el = document.createElement('span');
18885            el.className = 'code-particle';
18886            el.textContent = snippets[idx % snippets.length];
18887            var left = Math.random() * 94 + 2;
18888            var top = Math.random() * 88 + 6;
18889            var dur = (Math.random() * 10 + 9).toFixed(1);
18890            var delay = (Math.random() * 18).toFixed(1);
18891            var rot = (Math.random() * 26 - 13).toFixed(1);
18892            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18893            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';
18894            container.appendChild(el);
18895          })(i);
18896        }
18897      })();
18898    })();
18899  </script>
18900  <script nonce="{{ csp_nonce }}">
18901    (function () {
18902      var raw = {{ prefill_json|safe }};
18903      if (!raw || typeof raw !== 'object' || !raw.path) return;
18904      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
18905      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
18906      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
18907      setVal('path', raw.path || '');
18908      setVal('include_globs', raw.include_globs || '');
18909      setVal('exclude_globs', raw.exclude_globs || '');
18910      setVal('output_dir', raw.output_dir || '');
18911      setVal('report_title', raw.report_title || '');
18912      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
18913      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
18914      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
18915      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
18916      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
18917      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
18918      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
18919      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
18920      setChecked('generate_html', raw.generate_html !== false);
18921      setChecked('generate_pdf', !!raw.generate_pdf);
18922      // Trigger dynamic UI updates after pre-fill.
18923      setTimeout(function () {
18924        var pathEl = document.getElementById('path');
18925        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
18926        var policyEl = document.getElementById('mixed_line_policy');
18927        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
18928      }, 80);
18929    })();
18930  </script>
18931  <script nonce="{{ csp_nonce }}">
18932  (function(){
18933    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'}];
18934    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);});}
18935    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18936    function init(){
18937      var btn=document.getElementById('settings-btn');if(!btn)return;
18938      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18939      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>';
18940      document.body.appendChild(m);
18941      var g=document.getElementById('scheme-grid');
18942      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);});
18943      var cl=document.getElementById('settings-close');
18944      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);
18945      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');});
18946      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18947      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18948    }
18949    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18950  }());
18951  </script>
18952  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
18953    <div class="wb-ftip-arrow"></div>
18954    <span id="wb-ftip-text"></span>
18955  </div>
18956  <script nonce="{{ csp_nonce }}">(function(){
18957    var tip=document.getElementById('wb-ftip');
18958    var txt=document.getElementById('wb-ftip-text');
18959    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
18960    if(!tip||!txt)return;
18961    function pos(el){
18962      var r=el.getBoundingClientRect();
18963      tip.style.display='block';
18964      var tw=tip.offsetWidth;
18965      var lx=r.left+r.width/2-tw/2;
18966      if(lx<8)lx=8;
18967      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
18968      tip.style.left=lx+'px';
18969      tip.style.top=(r.bottom+8)+'px';
18970      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';}
18971    }
18972    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
18973      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
18974      el.addEventListener('mouseleave',function(){tip.style.display='none';});
18975    });
18976    window.addEventListener('blur',function(){tip.style.display='none';});
18977    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
18978  })();
18979  (function(){
18980    function fixArtifactHintSpacing(){
18981      var grid=document.querySelector('.artifact-grid');
18982      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
18983    }
18984    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
18985  }());
18986  (function(){
18987    var dot=document.getElementById('status-dot');
18988    var pingEl=document.getElementById('server-ping-ms');
18989    var tipEl=document.getElementById('server-tip-ping');
18990    var fm=document.getElementById('footer-mode');
18991    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)';}}
18992    function doPing(){
18993      var t0=performance.now();
18994      fetch('/healthz',{cache:'no-store'})
18995        .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);})
18996        .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)';}});
18997    }
18998    doPing();
18999    setInterval(doPing,5000);
19000    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');}
19001  })();
19002  </script>
19003  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
19004  <footer class="site-footer">
19005    local code analysis - metrics, history and reports
19006    &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>
19007    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19008    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19009    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19010    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19011  </footer>
19012</body>
19013</html>
19014"##,
19015    ext = "html"
19016)]
19017struct IndexTemplate {
19018    version: &'static str,
19019    prefill_json: String,
19020    csp_nonce: String,
19021    git_repo: String,
19022    git_ref: String,
19023    git_label_json: String,
19024    git_output_dir_json: String,
19025    server_mode: bool,
19026}
19027
19028// ── SplashTemplate ────────────────────────────────────────────────────────────
19029
19030#[derive(Template)]
19031#[template(
19032    source = r##"
19033<!doctype html>
19034<html lang="en">
19035<head>
19036  <meta charset="utf-8">
19037  <meta name="viewport" content="width=device-width, initial-scale=1">
19038  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
19039  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19040  <script type="application/ld+json">
19041  {
19042    "@context": "https://schema.org",
19043    "@type": "SoftwareApplication",
19044    "name": "oxide-sloc",
19045    "applicationCategory": "DeveloperApplication",
19046    "operatingSystem": "Windows, Linux",
19047    "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.",
19048    "softwareVersion": "{{ version }}",
19049    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
19050    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
19051    "url": "https://github.com/oxide-sloc/oxide-sloc",
19052    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
19053    "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",
19054    "programmingLanguage": "Rust",
19055    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
19056  }
19057  </script>
19058  <style nonce="{{ csp_nonce }}">
19059    :root {
19060      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19061      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19062      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19063      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19064      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19065    }
19066    body.dark-theme {
19067      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19068      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19069    }
19070    *{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;}
19071    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19072    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19073    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19074    .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;}
19075    @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));}}
19076    .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);}
19077    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19078    .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));}
19079    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19080    .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;}
19081    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19082    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19083    @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; } }
19084    .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;}
19085    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19086    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19087    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19088    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19089    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19090    .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;}
19091    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19092    .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);}
19093    .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;}
19094    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19095    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19096    .settings-modal-body{padding:14px 16px 16px;}
19097    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19098    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19099    .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;}
19100    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19101    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19102    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19103    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19104    .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;}
19105    .tz-select:focus{border-color:var(--oxide);}
19106    .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;}
19107    .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;}
19108    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
19109    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
19110    .hero{text-align:center;margin:0 auto 18px;}
19111    .hero-logo-wrap{display:inline-block;cursor:default;}
19112    .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;}
19113    .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;}
19114    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
19115    .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;}
19116    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%);}
19117    .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;
19118      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
19119      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
19120      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;}
19121    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
19122    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
19123    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;}
19124    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
19125    .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;}
19126    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
19127    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
19128    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
19129    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
19130    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
19131    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
19132    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
19133    .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;}
19134    .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;}
19135    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19136    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
19137    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19138    .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);}
19139    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
19140    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
19141    .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);}
19142    .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);}
19143    .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);}
19144    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
19145    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
19146    .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;}
19147    body.dark-theme .action-card-cta{color:var(--oxide);}
19148    .action-card.view .action-card-cta{color:var(--accent-2);}
19149    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
19150    .action-card.compare .action-card-cta{color:#7c3aed;}
19151    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
19152    .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);}
19153    .action-card.git-tools .action-card-cta{color:#15803d;}
19154    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
19155    .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);}
19156    .action-card.trend .action-card-cta{color:#0e7490;}
19157    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
19158    .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);}
19159    .action-card.automation .action-card-cta{color:#b45309;}
19160    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
19161    .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);}
19162    .action-card.test-metrics .action-card-cta{color:#be185d;}
19163    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
19164    .action-card:hover .action-card-cta{gap:12px;}
19165    .action-card.card-split{flex-direction:row;align-items:stretch;}
19166    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
19167    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
19168    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
19169    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
19170    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
19171    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
19172    .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;}
19173    .ac-badge.active{opacity:1;}
19174    .ac-badge.github{border-color:#555;color:#555;}
19175    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
19176    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
19177    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
19178    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
19179    body.dark-theme .ac-right-row{color:var(--muted);}
19180    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
19181    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
19182    .divider{height:1px;background:var(--line);margin:32px 0;}
19183    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
19184    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
19185    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
19186    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
19187      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
19188    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19189    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
19190    body.dark-theme .info-chip-val{color:var(--oxide);}
19191    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
19192    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
19193      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
19194      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
19195    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
19196      border:6px solid transparent;border-top-color:var(--text);}
19197    .info-chip:hover .info-chip-tip{display:block;}
19198    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
19199    .chip-slide.fading{filter:blur(5px);opacity:0;}
19200    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19201    .site-footer a{color:var(--muted);}
19202    .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;}
19203    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
19204    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
19205    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
19206    .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;}
19207    .lan-badge.local{background:var(--oxide-2);}
19208    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
19209    .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);}
19210    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
19211    .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;}
19212    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
19213    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
19214    .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;}
19215    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
19216    .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;}
19217    .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);}
19218    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
19219    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
19220    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
19221    .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;}
19222    @media (max-height: 1100px) {
19223      .page{padding-top:10px;}
19224      .hero{margin-bottom:10px;}
19225      .hero-logo{width:54px;height:60px;}
19226      .hero-logo-shadow{width:42px;}
19227      .hero-title{font-size:28px;}
19228      .hero-subtitle{font-size:13px;}
19229      .card-sections{gap:12px;margin-bottom:6px;}
19230      .card-section-grid-2,.card-section-grid-3{gap:10px;}
19231      .action-card{padding:8px 15px 8px;}
19232      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
19233      .action-card-icon svg{width:18px;height:18px;}
19234      .action-card-title{font-size:13px;}
19235      .action-card-desc{font-size:11px;margin-bottom:6px;}
19236      .action-card-cta{font-size:11px;}
19237      .ac-right-row{font-size:11px;}
19238      .divider{margin:14px 0;}
19239      .info-strip{gap:7px;margin-bottom:8px;}
19240      .info-chip{padding:7px 10px;}
19241      .info-chip-val{font-size:13px;}
19242      .info-chip-label{font-size:9px;}
19243      .site-footer{padding:8px 24px;font-size:12px;}
19244      .lan-local-hint{margin-top:8px;}
19245    }
19246    @media (max-height: 850px) {
19247      .page{padding-top:6px;}
19248      .hero{margin-bottom:6px;}
19249      .hero-logo{width:42px;height:46px;}
19250      .hero-title{font-size:22px;}
19251      .hero-subtitle{font-size:12px;}
19252      .card-sections{gap:10px;}
19253      .action-card-desc{margin-bottom:4px;}
19254      .divider{margin:8px 0;}
19255      .info-strip{margin-bottom:6px;}
19256      .lan-local-hint{margin-top:10px;}
19257    }
19258  </style>
19259</head>
19260<body>
19261  <div class="background-watermarks" aria-hidden="true">
19262    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19263    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19264    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19265    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19266    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19267    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19268    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19269  </div>
19270  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19271  <div class="top-nav">
19272    <div class="top-nav-inner">
19273      <a class="brand" href="/">
19274        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19275        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19276      </a>
19277      <div class="nav-right">
19278        <a class="nav-pill" href="/">Home</a>
19279        <div class="nav-dropdown">
19280          <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>
19281          <div class="nav-dropdown-menu">
19282            <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>
19283          </div>
19284        </div>
19285        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19286        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19287        <div class="nav-dropdown">
19288          <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>
19289          <div class="nav-dropdown-menu">
19290            <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>
19291          </div>
19292        </div>
19293        <div class="server-status-wrap" id="server-status-wrap">
19294          <div class="nav-pill server-online-pill" id="server-status-pill">
19295            <span class="status-dot" id="status-dot"></span>
19296            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19297            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19298          </div>
19299          <div class="server-status-tip">
19300            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
19301            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19302          </div>
19303        </div>
19304        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19305          <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>
19306        </button>
19307        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19308          <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>
19309          <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>
19310        </button>
19311      </div>
19312    </div>
19313  </div>
19314
19315  <div class="page">
19316    <div class="hero">
19317      <div class="hero-logo-wrap" id="hero-logo-wrap">
19318        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
19319      </div>
19320      <div class="hero-logo-shadow"></div>
19321      <div class="hero-title-wrap">
19322        <div class="hero-title-aura" aria-hidden="true"></div>
19323        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
19324      </div>
19325      <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>
19326    </div>
19327
19328    <div class="card-sections">
19329
19330      <div>
19331        <div class="card-section-label">Analysis</div>
19332        <div class="card-section-grid-2">
19333          <a class="action-card scan card-split" href="/scan-setup">
19334            <div class="action-card-left">
19335              <div class="action-card-icon">
19336                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19337              </div>
19338              <div class="action-card-title">Scan Project</div>
19339              <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>
19340              <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>
19341            </div>
19342            <div class="action-card-sep"></div>
19343            <div class="action-card-right">
19344              <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>
19345              <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>
19346              <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>
19347              <div class="ac-right-stat" id="acp-scan-stat"></div>
19348            </div>
19349          </a>
19350          <a class="action-card test-metrics card-split" href="/test-metrics">
19351            <div class="action-card-left">
19352              <div class="action-card-icon">
19353                <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>
19354              </div>
19355              <div class="action-card-title">Test Metrics</div>
19356              <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>
19357              <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>
19358            </div>
19359            <div class="action-card-sep"></div>
19360            <div class="action-card-right">
19361              <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>
19362              <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>
19363              <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>
19364              <div class="ac-right-stat" id="acp-test-stat"></div>
19365            </div>
19366          </a>
19367        </div>
19368      </div>
19369
19370      <div>
19371        <div class="card-section-label">Reports &amp; Insights</div>
19372        <div class="card-section-grid-3">
19373          <a class="action-card view" href="/view-reports">
19374            <div class="action-card-icon">
19375              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
19376            </div>
19377            <div class="action-card-title">View Reports</div>
19378            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
19379            <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>
19380          </a>
19381          <a class="action-card compare" href="/compare-scans">
19382            <div class="action-card-icon">
19383              <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>
19384            </div>
19385            <div class="action-card-title">Compare Scans</div>
19386            <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>
19387            <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>
19388          </a>
19389          <a class="action-card trend" href="/trend-reports">
19390            <div class="action-card-icon">
19391              <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>
19392            </div>
19393            <div class="action-card-title">Trend Report</div>
19394            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
19395            <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>
19396          </a>
19397        </div>
19398      </div>
19399
19400      <div>
19401        <div class="card-section-label">Developer Tools</div>
19402        <div class="card-section-grid-2">
19403          <a class="action-card git-tools card-split" href="/git-browser">
19404            <div class="action-card-left">
19405              <div class="action-card-icon">
19406                <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>
19407              </div>
19408              <div class="action-card-title">Git Browser</div>
19409              <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>
19410              <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>
19411            </div>
19412            <div class="action-card-sep"></div>
19413            <div class="action-card-right">
19414              <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>
19415              <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>
19416              <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>
19417            </div>
19418          </a>
19419          <a class="action-card automation card-split" href="/integrations">
19420            <div class="action-card-left">
19421              <div class="action-card-icon">
19422                <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>
19423              </div>
19424              <div class="action-card-title">Integrations</div>
19425              <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>
19426              <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>
19427            </div>
19428            <div class="action-card-sep"></div>
19429            <div class="action-card-right">
19430              <div class="ac-badges-grid">
19431                <span class="ac-badge github"     id="acp-gh">GitHub</span>
19432                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
19433                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
19434                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
19435              </div>
19436              <div class="ac-right-stat" id="acp-int-stat"></div>
19437            </div>
19438          </a>
19439        </div>
19440      </div>
19441
19442    </div>
19443
19444    {% if server_mode %}
19445    <div class="lan-card server">
19446      <div class="lan-card-header">
19447        <span class="lan-badge">LAN server</span>
19448        Accessible on your network
19449      </div>
19450      {% if let Some(ip) = lan_ip %}
19451      <div class="lan-url-row">
19452        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
19453        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
19454          <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>
19455          Copy URL
19456        </button>
19457      </div>
19458      <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>
19459      {% if has_api_key %}
19460      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
19461      {% endif %}
19462      {% else %}
19463      <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>
19464      {% endif %}
19465    </div>
19466    {% endif %}
19467
19468    <div class="divider"></div>
19469
19470    <div class="info-strip">
19471      <div class="info-chip">
19472        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
19473        <div class="chip-slide">
19474          <div class="info-chip-val">60</div>
19475          <div class="info-chip-label">Languages</div>
19476        </div>
19477      </div>
19478      <div class="info-chip">
19479        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
19480        <div class="chip-slide">
19481          <div class="info-chip-val">100%</div>
19482          <div class="info-chip-label">Self-contained</div>
19483        </div>
19484      </div>
19485      <div class="info-chip">
19486        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
19487        <div class="chip-slide">
19488          <div class="info-chip-val">HTML+PDF</div>
19489          <div class="info-chip-label">Exportable reports</div>
19490        </div>
19491      </div>
19492      <div class="info-chip">
19493        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
19494        <div class="chip-slide">
19495          <div class="info-chip-val">Webhook</div>
19496          <div class="info-chip-label">3 platforms</div>
19497        </div>
19498      </div>
19499      <div class="info-chip">
19500        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
19501        <div class="chip-slide">
19502          <div class="info-chip-val">IEEE</div>
19503          <div class="info-chip-label">1045-1992</div>
19504        </div>
19505      </div>
19506    </div>
19507
19508    {% if lan_ip.is_none() %}
19509    <div class="lan-local-hint">
19510      <strong>Want teammates on the same network to access this?</strong><br>
19511      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
19512    </div>
19513    {% endif %}
19514  </div>
19515
19516  <footer class="site-footer">
19517    local code analysis - metrics, history and reports
19518    &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>
19519    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19520    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19521    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19522    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19523  </footer>
19524
19525  <script nonce="{{ csp_nonce }}">
19526    (function () {
19527      var storageKey = 'oxide-sloc-theme';
19528      var body = document.body;
19529      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19530      var toggle = document.getElementById('theme-toggle');
19531      if (toggle) toggle.addEventListener('click', function () {
19532        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19533        body.classList.toggle('dark-theme', next === 'dark');
19534        try { localStorage.setItem(storageKey, next); } catch(e) {}
19535      });
19536      var copyBtn = document.getElementById('lan-copy-btn');
19537      if (copyBtn) copyBtn.addEventListener('click', function() {
19538        var btn = this;
19539        var el = document.getElementById('lan-url-val');
19540        if (!el) return;
19541        var url = el.textContent.trim();
19542        if (navigator.clipboard) {
19543          navigator.clipboard.writeText(url).then(function() {
19544            var orig = btn.innerHTML;
19545            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!';
19546            setTimeout(function() { btn.innerHTML = orig; }, 1800);
19547          });
19548        }
19549      });
19550      (function randomizeWatermarks() {
19551        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19552        if (!wms.length) return;
19553        var placed = [];
19554        function tooClose(top, left) {
19555          for (var i = 0; i < placed.length; i++) {
19556            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19557            if (dt < 16 && dl < 12) return true;
19558          }
19559          return false;
19560        }
19561        function pick(leftBand) {
19562          for (var attempt = 0; attempt < 50; attempt++) {
19563            var top = Math.random() * 88 + 2;
19564            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19565            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19566          }
19567          var top = Math.random() * 88 + 2;
19568          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19569          placed.push([top, left]); return [top, left];
19570        }
19571        var half = Math.floor(wms.length / 2);
19572        wms.forEach(function (img, i) {
19573          var pos = pick(i < half);
19574          var size = Math.floor(Math.random() * 100 + 120);
19575          var rot = (Math.random() * 360).toFixed(1);
19576          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19577          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;
19578        });
19579      })();
19580
19581      (function spawnCodeParticles() {
19582        var container = document.getElementById('code-particles');
19583        if (!container) return;
19584        var snippets = [
19585          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19586          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19587          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19588          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19589          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19590        ];
19591        var count = 38;
19592        for (var i = 0; i < count; i++) {
19593          (function(idx) {
19594            var el = document.createElement('span');
19595            el.className = 'code-particle';
19596            var text = snippets[idx % snippets.length];
19597            el.textContent = text;
19598            var left = Math.random() * 94 + 2;
19599            var top = Math.random() * 88 + 6;
19600            var dur = (Math.random() * 10 + 9).toFixed(1);
19601            var delay = (Math.random() * 18).toFixed(1);
19602            var rot = (Math.random() * 26 - 13).toFixed(1);
19603            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19604            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
19605              + '--rot:' + rot + 'deg;--op:' + op + ';'
19606              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
19607            container.appendChild(el);
19608          })(i);
19609        }
19610      })();
19611      (function heroAnimations() {
19612        var sub = document.getElementById('hero-subtitle');
19613        if (sub) {
19614          var full = sub.textContent.trim();
19615          sub.textContent = '';
19616          sub.style.opacity = '1';
19617          var cursor = document.createElement('span');
19618          cursor.className = 'hero-cursor';
19619          sub.appendChild(cursor);
19620          var i = 0;
19621          setTimeout(function() {
19622            var iv = setInterval(function() {
19623              if (i < full.length) {
19624                sub.insertBefore(document.createTextNode(full[i]), cursor);
19625                i++;
19626              } else {
19627                clearInterval(iv);
19628                setTimeout(function() {
19629                  cursor.style.transition = 'opacity 1s ease';
19630                  cursor.style.opacity = '0';
19631                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
19632                }, 2400);
19633              }
19634            }, 11);
19635          }, 374);
19636        }
19637      })();
19638      (function logoBob() {
19639        var logo = document.querySelector('.hero-logo');
19640        var shadow = document.querySelector('.hero-logo-shadow');
19641        if (!logo) return;
19642        var cycleStart = null, cycleDur = 3600;
19643        var peakY = -14, peakScale = 1.07, peakRot = 0;
19644        function newCycle() {
19645          cycleDur = 3000 + Math.random() * 1840;
19646          peakY = -(9 + Math.random() * 13.8);
19647          peakScale = 1.04 + Math.random() * 0.081;
19648          peakRot = (Math.random() * 11.5 - 5.75);
19649        }
19650        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
19651        newCycle();
19652        function frame(ts) {
19653          if (cycleStart === null) cycleStart = ts;
19654          var t = (ts - cycleStart) / cycleDur;
19655          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
19656          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
19657          var y = peakY * phase;
19658          var sc = 1 + (peakScale - 1) * phase;
19659          var rot = peakRot * Math.sin(Math.PI * phase);
19660          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
19661          if (shadow) {
19662            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
19663            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
19664          }
19665          requestAnimationFrame(frame);
19666        }
19667        requestAnimationFrame(frame);
19668      })();
19669      (function mouseEffects() {
19670        var heroTitle = document.getElementById('hero-title');
19671        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
19672        function tick() {
19673          raf = null;
19674          if (heroTitle) {
19675            var r = heroTitle.getBoundingClientRect();
19676            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
19677            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
19678            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
19679          }
19680        }
19681        document.addEventListener('mousemove', function(e) {
19682          mx = e.clientX; my = e.clientY;
19683          if (!raf) raf = requestAnimationFrame(tick);
19684        });
19685        document.addEventListener('mouseleave', function() {
19686          if (heroTitle) {
19687            heroTitle.style.transition = 'transform 0.5s ease';
19688            heroTitle.style.transform = '';
19689            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
19690          }
19691        });
19692        document.querySelectorAll('.action-card').forEach(function(card) {
19693          card.addEventListener('mousemove', function(e) {
19694            var rect = card.getBoundingClientRect();
19695            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
19696            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
19697            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
19698            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
19699          });
19700          card.addEventListener('mouseleave', function() {
19701            card.style.transition = '';
19702            card.style.transform = '';
19703          });
19704        });
19705      })();
19706      (function chipSlideshow() {
19707        var slides = [
19708          [{v:'60',l:'Languages'},{v:'Rust · Go · Python',l:'and 57 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
19709          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
19710          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
19711          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
19712          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
19713        ];
19714        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
19715        var indices = [0,0,0,0,0];
19716        var paused = [false,false,false,false,false];
19717        chips.forEach(function(chip, i) {
19718          chip.addEventListener('mouseenter', function() { paused[i] = true; });
19719          chip.addEventListener('mouseleave', function() { paused[i] = false; });
19720        });
19721        function advance(i) {
19722          if (paused[i]) return;
19723          var chip = chips[i];
19724          var inner = chip.querySelector('.chip-slide');
19725          if (!inner) return;
19726          inner.classList.add('fading');
19727          setTimeout(function() {
19728            indices[i] = (indices[i] + 1) % slides[i].length;
19729            var s = slides[i][indices[i]];
19730            chip.querySelector('.info-chip-val').textContent = s.v;
19731            chip.querySelector('.info-chip-label').textContent = s.l;
19732            inner.classList.remove('fading');
19733          }, 720);
19734        }
19735        setInterval(function() {
19736          chips.forEach(function(chip, i) { advance(i); });
19737        }, 6000);
19738      })();
19739      (function cardLiveData() {
19740        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
19741          var el = document.getElementById('acp-scan-stat');
19742          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
19743        }).catch(function(){});
19744        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
19745          var el = document.getElementById('acp-test-stat');
19746          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
19747        }).catch(function(){});
19748        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
19749          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
19750          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
19751          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
19752          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
19753          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
19754          var stat = document.getElementById('acp-int-stat');
19755          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
19756        }).catch(function(){});
19757        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
19758          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
19759        }).catch(function(){});
19760      })();
19761    })();
19762  </script>
19763  <script nonce="{{ csp_nonce }}">
19764  (function(){
19765    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'}];
19766    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);});}
19767    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19768    function init(){
19769      var btn=document.getElementById('settings-btn');if(!btn)return;
19770      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19771      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>';
19772      document.body.appendChild(m);
19773      var g=document.getElementById('scheme-grid');
19774      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);});
19775      var cl=document.getElementById('settings-close');
19776      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);
19777      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');});
19778      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19779      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19780    }
19781    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19782  }());
19783  </script>
19784  <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>
19785</body>
19786</html>
19787"##,
19788    ext = "html"
19789)]
19790struct SplashTemplate {
19791    csp_nonce: String,
19792    server_mode: bool,
19793    lan_ip: Option<String>,
19794    port: u16,
19795    version: &'static str,
19796    has_api_key: bool,
19797}
19798
19799// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
19800
19801#[derive(Template)]
19802#[template(
19803    source = r##"
19804<!doctype html>
19805<html lang="en">
19806<head>
19807  <meta charset="utf-8">
19808  <meta name="viewport" content="width=device-width, initial-scale=1">
19809  <title>OxideSLOC — Start a Scan</title>
19810  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19811  <style nonce="{{ csp_nonce }}">
19812    :root {
19813      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
19814      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19815      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19816      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19817      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19818    }
19819    body.dark-theme {
19820      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19821      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19822    }
19823    *{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;}
19824    .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);}
19825    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19826    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
19827    .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));}
19828    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
19829    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19830    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19831    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19832    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19833    @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; } }
19834    .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;}
19835    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19836    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19837    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19838    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19839    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19840    .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;}
19841    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19842    .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);}
19843    .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;}
19844    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19845    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19846    .settings-modal-body{padding:14px 16px 16px;}
19847    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19848    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19849    .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;}
19850    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19851    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19852    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19853    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19854    .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;}
19855    .tz-select:focus{border-color:var(--oxide);}
19856    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
19857    .page-header{text-align:center;margin-bottom:16px;}
19858    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
19859    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
19860    /* Cards */
19861    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
19862    .option-card-wrap{position:relative;}
19863    .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;}
19864    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
19865    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19866    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
19867    .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;}
19868    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
19869    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
19870    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
19871    .card-top-row{display:flex;align-items:center;gap:20px;}
19872    /* Two-column layout inside each card */
19873    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
19874    .card-left{display:flex;align-items:flex-start;min-width:0;}
19875    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
19876    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
19877    .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);}
19878    .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);}
19879    .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);}
19880    .card-text{min-width:0;}
19881    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
19882    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
19883    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
19884    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
19885    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
19886    /* Right CTA column */
19887    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
19888    .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;}
19889    /* Re-scan count badge */
19890    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
19891    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
19892    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
19893    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
19894    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
19895    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
19896    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
19897    body.dark-theme .btn-secondary{color:var(--oxide);}
19898    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
19899    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
19900    /* File input overlay — must be full-width so it aligns with other card-right buttons */
19901    .file-input-wrap{position:relative;width:100%;}
19902    .file-input-wrap .btn{width:100%;}
19903    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
19904    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19905    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19906    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19907    .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;}
19908    @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));}}
19909    /* Recent list (card 3 — full-width section below header) */
19910    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
19911    .recent-list{display:flex;flex-direction:column;gap:8px;}
19912    .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;}
19913    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
19914    .recent-item-info{flex:1;min-width:0;}
19915    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
19916    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
19917    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
19918    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
19919    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19920    .site-footer a{color:var(--muted);}
19921    @media(max-width:680px){
19922      .card-body{grid-template-columns:1fr;}
19923      .card-right{flex-direction:row;flex-wrap:wrap;}
19924      .btn{flex:1;}
19925    }
19926    .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;}
19927    .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;}
19928    .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;}
19929  </style>
19930</head>
19931<body>
19932  <div class="background-watermarks" aria-hidden="true">
19933    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19934    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19935    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19936    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19937    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19938    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19939    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19940  </div>
19941  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19942  <div class="top-nav">
19943    <div class="top-nav-inner">
19944      <a class="brand" href="/">
19945        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19946        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19947      </a>
19948      <div class="nav-right">
19949        <a class="nav-pill" href="/">Home</a>
19950        <div class="nav-dropdown">
19951          <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>
19952          <div class="nav-dropdown-menu">
19953            <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>
19954          </div>
19955        </div>
19956        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19957        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19958        <div class="nav-dropdown">
19959          <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>
19960          <div class="nav-dropdown-menu">
19961            <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>
19962          </div>
19963        </div>
19964        <div class="server-status-wrap" id="server-status-wrap">
19965          <div class="nav-pill server-online-pill" id="server-status-pill">
19966            <span class="status-dot" id="status-dot"></span>
19967            <span id="server-status-label">Server</span>
19968            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19969          </div>
19970          <div class="server-status-tip">
19971            OxideSLOC is running — accessible on your network.
19972            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19973          </div>
19974        </div>
19975        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19976          <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>
19977        </button>
19978        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19979          <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>
19980          <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>
19981        </button>
19982      </div>
19983    </div>
19984  </div>
19985
19986  <div class="page">
19987    <div class="page-header">
19988      <h1>How would you like to scan?</h1>
19989      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
19990    </div>
19991
19992    <div class="option-grid">
19993
19994      <!-- Option 1: New scan -->
19995      <div class="option-card-wrap">
19996        <div class="option-card">
19997        <div class="option-icon new-scan">
19998          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19999        </div>
20000        <div class="card-body">
20001          <div class="card-left">
20002            <div class="card-text">
20003              <div class="option-title">Start a new scan</div>
20004              <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>
20005              <ul class="feature-list">
20006                <li>Live project scope preview before you run</li>
20007                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
20008                <li>HTML, PDF, and JSON output — your choice</li>
20009              </ul>
20010            </div>
20011          </div>
20012          <div class="card-right">
20013            <a class="btn btn-primary" href="/scan">
20014              Configure &amp; scan
20015              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
20016            </a>
20017            <p class="card-tip">Full 4-step setup · all options</p>
20018          </div>
20019        </div>
20020        </div>
20021      </div>
20022
20023      <!-- Option 2: Load from config file -->
20024      <div class="option-card-wrap">
20025        <div class="option-card">
20026        <div class="option-icon load-config">
20027          <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>
20028        </div>
20029        <div class="card-body">
20030          <div class="card-left">
20031            <div class="card-text">
20032              <div class="option-title">Load a saved config</div>
20033              <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>
20034              <ul class="feature-list">
20035                <li>All 15 settings restored from the file</li>
20036                <li>Fully editable — change path or output dir</li>
20037                <li>Works with any scan-config.json</li>
20038              </ul>
20039            </div>
20040          </div>
20041          <div class="card-right">
20042            <div class="file-input-wrap">
20043              <button class="btn btn-secondary" id="load-config-btn" type="button">
20044                <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>
20045                Choose config file
20046              </button>
20047              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
20048            </div>
20049            <p class="card-tip" id="config-file-name">Exported after every scan</p>
20050          </div>
20051        </div>
20052        </div>
20053      </div>
20054
20055      <!-- Option 3: Re-scan recent project -->
20056      <div class="option-card-wrap">
20057        <div class="option-card" id="recent-card">
20058        <div class="card-top-row">
20059          <div class="option-icon rescan">
20060            <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>
20061          </div>
20062          <div class="card-body">
20063            <div class="card-left">
20064              <div class="card-text">
20065                <div class="option-title">Re-scan a recent project</div>
20066                <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>
20067                <ul class="feature-list">
20068                  <li>All 15+ settings restored from the saved config</li>
20069                  <li>Path and output dir are editable before running</li>
20070                  <li>Only scans with a saved config appear here</li>
20071                </ul>
20072              </div>
20073            </div>
20074            <div class="card-right">
20075              <div class="rescan-count-box">
20076                <div class="rescan-count-num" id="rescan-count-num">—</div>
20077                <div class="rescan-count-label">saved configs</div>
20078              </div>
20079              <a class="btn btn-secondary" href="/view-reports">
20080                <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>
20081                View all runs
20082              </a>
20083              <p class="card-tip">Opens run history</p>
20084            </div>
20085          </div>
20086        </div>
20087        <div class="section-divider"></div>
20088        <div class="recent-list" id="recent-list">
20089          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
20090        </div>
20091        </div>
20092      </div>
20093
20094    </div>
20095  </div>
20096
20097  <footer class="site-footer">
20098    local code analysis - metrics, history and reports
20099    &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>
20100    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20101    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20102    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20103    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20104  </footer>
20105
20106  <script nonce="{{ csp_nonce }}">
20107    (function () {
20108      var storageKey = 'oxide-sloc-theme';
20109      var body = document.body;
20110      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20111      var toggle = document.getElementById('theme-toggle');
20112      if (toggle) toggle.addEventListener('click', function () {
20113        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20114        body.classList.toggle('dark-theme', next === 'dark');
20115        try { localStorage.setItem(storageKey, next); } catch(e) {}
20116      });
20117
20118      (function randomizeWatermarks() {
20119        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20120        if (!wms.length) return;
20121        var placed = [];
20122        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; }
20123        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]; }
20124        var half = Math.floor(wms.length / 2);
20125        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; });
20126      })();
20127      (function spawnCodeParticles() {
20128        var container = document.getElementById('code-particles');
20129        if (!container) return;
20130        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'];
20131        var count = 38;
20132        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); }
20133      })();
20134      // Recent scans data injected from server
20135      var recentScans = {{ recent_scans_json|safe }};
20136
20137      function configToParams(cfg) {
20138        var p = new URLSearchParams();
20139        p.set('prefilled', '1');
20140        if (cfg.path) p.set('path', cfg.path);
20141        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
20142        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
20143        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
20144        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
20145        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
20146        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
20147        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
20148        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
20149        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
20150        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
20151        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
20152        if (cfg.report_title) p.set('report_title', cfg.report_title);
20153        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
20154        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
20155        return p;
20156      }
20157
20158      // Build recent scan list (capped at 3 visible entries)
20159      var list = document.getElementById('recent-list');
20160      var noNote = document.getElementById('no-recent-note');
20161      var hasAny = false;
20162      var MAX_RECENT = 3;
20163      if (Array.isArray(recentScans)) {
20164        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
20165        var shown = 0;
20166        validEntries.forEach(function (entry) {
20167          if (shown >= MAX_RECENT) return;
20168          shown++;
20169          hasAny = true;
20170          var item = document.createElement('div');
20171          item.className = 'recent-item';
20172          item.title = 'Restore all settings and open wizard';
20173          item.innerHTML =
20174            '<div class="recent-item-info">' +
20175              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
20176              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
20177            '</div>' +
20178            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
20179          item.addEventListener('click', function () {
20180            var params = configToParams(entry.config);
20181            window.location.href = '/scan?' + params.toString();
20182          });
20183          list.appendChild(item);
20184        });
20185        if (validEntries.length > MAX_RECENT) {
20186          var moreEl = document.createElement('div');
20187          moreEl.className = 'recent-more-link';
20188          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
20189          list.appendChild(moreEl);
20190        }
20191      }
20192      if (hasAny && noNote) noNote.style.display = 'none';
20193      // Update count badge
20194      var countEl = document.getElementById('rescan-count-num');
20195      if (countEl) {
20196        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
20197        countEl.textContent = total > 0 ? total : '0';
20198      }
20199
20200      // Config file loader
20201      var fileInput = document.getElementById('config-file-input');
20202      var fileName = document.getElementById('config-file-name');
20203      var loadBtn = document.getElementById('load-config-btn');
20204      // Wire the visible button to open the hidden file picker.
20205      if (loadBtn && fileInput) {
20206        loadBtn.addEventListener('click', function () { fileInput.click(); });
20207      }
20208      if (fileInput) {
20209        fileInput.addEventListener('change', function () {
20210          var file = fileInput.files && fileInput.files[0];
20211          if (!file) return;
20212          if (fileName) fileName.textContent = '✓ ' + file.name;
20213          var reader = new FileReader();
20214          reader.onload = function (e) {
20215            try {
20216              var cfg = JSON.parse(e.target.result);
20217              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
20218              var params = configToParams(cfg);
20219              window.location.href = '/scan?' + params.toString();
20220            } catch (err) {
20221              alert('Could not parse config file: ' + err.message);
20222            }
20223          };
20224          reader.readAsText(file);
20225        });
20226      }
20227
20228      function escHtml(s) {
20229        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
20230      }
20231    })();
20232  </script>
20233  <script nonce="{{ csp_nonce }}">
20234  (function(){
20235    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'}];
20236    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);});}
20237    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20238    function init(){
20239      var btn=document.getElementById('settings-btn');if(!btn)return;
20240      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20241      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>';
20242      document.body.appendChild(m);
20243      var g=document.getElementById('scheme-grid');
20244      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);});
20245      var cl=document.getElementById('settings-close');
20246      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);
20247      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');});
20248      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20249      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20250    }
20251    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20252  }());
20253  </script>
20254  <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]';
20255  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;}
20256  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>
20257</body>
20258</html>
20259"##,
20260    ext = "html"
20261)]
20262struct ScanSetupTemplate {
20263    version: &'static str,
20264    recent_scans_json: String,
20265    csp_nonce: String,
20266}
20267
20268#[derive(Template)]
20269#[template(
20270    source = r##"
20271<!doctype html>
20272<html lang="en">
20273<head>
20274  <meta charset="utf-8">
20275  <meta name="viewport" content="width=device-width, initial-scale=1">
20276  <title>OxideSLOC | {{ report_title }} | Report</title>
20277  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20278  <style nonce="{{ csp_nonce }}">
20279    :root {
20280      --radius: 18px;
20281      --bg: #f5efe8;
20282      --surface: rgba(255,255,255,0.82);
20283      --surface-2: #fbf7f2;
20284      --surface-3: #efe6dc;
20285      --line: #e6d0bf;
20286      --line-strong: #dcb89f;
20287      --text: #43342d;
20288      --muted: #7b675b;
20289      --muted-2: #a08777;
20290      --nav: #b85d33;
20291      --nav-2: #7a371b;
20292      --accent: #6f9bff;
20293      --accent-2: #4a78ee;
20294      --oxide: #d37a4c;
20295      --oxide-2: #b35428;
20296      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
20297      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
20298      --success-bg: #e8f5ed;
20299      --success-text: #1a8f47;
20300      --info-bg: #eef3ff;
20301      --info-text: #4467d8;
20302    }
20303
20304    body.dark-theme {
20305      --bg: #1b1511;
20306      --surface: #261c17;
20307      --surface-2: #2d221d;
20308      --surface-3: #372922;
20309      --line: #524238;
20310      --line-strong: #6c5649;
20311      --text: #f5ece6;
20312      --muted: #c7b7aa;
20313      --muted-2: #aa9485;
20314      --nav: #b85d33;
20315      --nav-2: #7a371b;
20316      --accent: #6f9bff;
20317      --accent-2: #4a78ee;
20318      --oxide: #d37a4c;
20319      --oxide-2: #b35428;
20320      --shadow: 0 18px 42px rgba(0,0,0,0.28);
20321      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
20322      --success-bg: #163927;
20323      --success-text: #8fe2a8;
20324      --info-bg: #1c2847;
20325      --info-text: #a9c1ff;
20326    }
20327
20328    * { box-sizing: border-box; }
20329    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); }
20330    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
20331    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
20332    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
20333    .top-nav, .page { position: relative; z-index: 2; }
20334    .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); }
20335    .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; }
20336    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
20337    .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)); }
20338    .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; }
20339    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
20340    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
20341    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
20342    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
20343    .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; }
20344    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
20345    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20346    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
20347    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20348    @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; } }
20349    .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; }
20350    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
20351    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
20352    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
20353    .theme-toggle .icon-sun { display:none; }
20354    body.dark-theme .theme-toggle .icon-sun { display:block; }
20355    body.dark-theme .theme-toggle .icon-moon { display:none; }
20356    .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;}
20357    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20358    .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);}
20359    .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;}
20360    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20361    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20362    .settings-modal-body{padding:14px 16px 16px;}
20363    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20364    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20365    .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;}
20366    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20367    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20368    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20369    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20370    .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;}
20371    .tz-select:focus{border-color:var(--oxide);}
20372    .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; }
20373    .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;}
20374    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
20375    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
20376    .hero, .panel { padding: 22px; }
20377    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
20378    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
20379    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
20380    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
20381    .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; }
20382    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
20383    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
20384    .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; }
20385    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
20386    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
20387    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
20388    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
20389    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
20390    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
20391    .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; }
20392    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
20393    .delta-card-val { font-size:16px; font-weight:800; }
20394    .delta-card-val.pos { color:#1e7e34; }
20395    .delta-card-val.neg { color:var(--neg); }
20396    .delta-card-val.mod { color:#b35428; }
20397    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
20398    .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; }
20399    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20400    .delta-card-inline:hover .delta-card-tip { opacity:1; }
20401    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
20402    .compare-ts { font-size:13px; color:var(--muted); }
20403    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
20404    .compare-arrow { color: var(--muted); }
20405    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
20406    .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; }
20407    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
20408    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
20409    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
20410    .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; }
20411    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
20412    .run-mgmt-card .action-buttons { justify-content:center; }
20413    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
20414    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
20415    .button, .copy-button {
20416      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;
20417    }
20418    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
20419    @keyframes spin { to { transform: rotate(360deg); } }
20420    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
20421    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
20422    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
20423    .path-item strong { display: block; margin-bottom: 6px; }
20424    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
20425    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
20426    .path-subitem { flex: 1; }
20427    .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); }
20428    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); }
20429    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
20430    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
20431    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
20432    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
20433    th { color: var(--muted); font-weight: 700; }
20434    tr:last-child td { border-bottom: none; }
20435    #subm-tbl col:nth-child(1){width:15%;}
20436    #subm-tbl col:nth-child(2){width:31%;}
20437    #subm-tbl col:nth-child(3){width:9%;}
20438    #subm-tbl col:nth-child(4){width:9%;}
20439    #subm-tbl col:nth-child(5){width:9%;}
20440    #subm-tbl col:nth-child(6){width:9%;}
20441    #subm-tbl col:nth-child(7){width:9%;}
20442    #subm-tbl col:nth-child(8){width:9%;}
20443    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
20444    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
20445    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
20446    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
20447    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
20448    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
20449    .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; }
20450    .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; }
20451    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
20452    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
20453    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
20454    .muted { color: var(--muted); }
20455    /* Run-ID chip row (mirrors HTML report) */
20456    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
20457    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
20458    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
20459    .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; }
20460    .run-id-chip[data-copy] { cursor:pointer; }
20461    a.run-id-chip { text-decoration:none; cursor:pointer; }
20462    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
20463    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
20464    .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; }
20465    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
20466    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20467    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
20468    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
20469    a.commit-link-value { color:inherit; text-decoration:none; }
20470    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
20471    .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; }
20472    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20473    .run-id-chip:hover .chip-tooltip { opacity:1; }
20474    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
20475    .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; }
20476    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
20477    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
20478    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
20479    /* Meta chips row */
20480    .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%; }
20481    .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; }
20482    .meta-chip:last-child { border-right:none; }
20483    .meta-chip b { color:var(--text); font-weight:700; }
20484    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20485    .site-footer a{color:var(--muted);}
20486    .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; }
20487    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
20488    .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; }
20489    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
20490    /* Stat chips (matches HTML report) */
20491    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
20492    @media(max-width:1200px){.summary-strip{grid-template-columns:repeat(4,1fr);}}
20493    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20494    .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; }
20495    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
20496    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
20497    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
20498    .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; }
20499    .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); }
20500    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20501    .stat-chip:hover .stat-chip-tip { opacity:1; }
20502    .cocomo-box { background:var(--surface-2); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
20503    .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; }
20504    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
20505    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
20506    .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); }
20507    .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); }
20508    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
20509    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; }
20510    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
20511    /* Submodule panel */
20512    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
20513    /* Metrics tables stack */
20514    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
20515    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
20516    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
20517    .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)); }
20518    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
20519    /* Metrics table */
20520    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
20521    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
20522    .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; }
20523    .metrics-table thead th:not(:first-child) { text-align: right; }
20524    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
20525    .metrics-table tbody tr:last-child td { border-bottom: none; }
20526    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
20527    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
20528    .metrics-table tbody tr:hover td { background: var(--surface-2); }
20529    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
20530    .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; }
20531    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
20532    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
20533    .mt-val-pos { color: var(--pos); font-weight: 700; }
20534    .mt-val-neg { color: var(--neg); font-weight: 700; }
20535    .mt-val-zero { color: var(--muted); }
20536    .mt-val-mod { color: var(--oxide-2); }
20537    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
20538    @media (max-width: 1180px) {
20539      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
20540      .nav-project-slot, .nav-status { justify-content:flex-start; }
20541      .hero-top { flex-direction: column; }
20542      .run-mgmt-strip { flex-direction: column; }
20543    }
20544    .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;}
20545    @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));}}
20546    .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;}
20547    /* ── Result-page chart controls ─────────────────────────────────────────── */
20548    .r-chart-section{margin-bottom:24px;}
20549    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
20550    .section-pair > .panel{flex-shrink:0;}
20551    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
20552    .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;}
20553    .r-chart-select:focus{border-color:var(--accent);}
20554    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
20555    .r-chart-container svg{display:block;width:100%;height:auto;}
20556    .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;}
20557    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
20558    .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;}
20559    .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);}
20560    .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;}
20561    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
20562    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
20563    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
20564    .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;}
20565    .r-chart-modal-close:hover{opacity:.7;}
20566    body.dark-theme .r-chart-modal{background:var(--surface);}
20567    .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;}
20568    .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);}
20569    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
20570    .lang-bar-row:hover{transform:translateY(-2px);}
20571    .lang-bar-row .rchit:hover{filter:none;transform:none;}
20572    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
20573    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
20574    .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;}
20575    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
20576    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
20577    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
20578    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
20579    #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;}
20580    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
20581    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
20582    .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;}
20583    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
20584    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
20585    .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;}
20586    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
20587    .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;}
20588    .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;}
20589    body.has-report-banner .top-nav{top:27px;}
20590    body.has-report-banner{padding-bottom:27px;}
20591  </style>
20592</head>
20593<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
20594  <div class="background-watermarks" aria-hidden="true">
20595    <img src="/images/logo/logo-text.png" alt="" />
20596    <img src="/images/logo/logo-text.png" alt="" />
20597    <img src="/images/logo/logo-text.png" alt="" />
20598    <img src="/images/logo/logo-text.png" alt="" />
20599    <img src="/images/logo/logo-text.png" alt="" />
20600    <img src="/images/logo/logo-text.png" alt="" />
20601    <img src="/images/logo/logo-text.png" alt="" />
20602    <img src="/images/logo/logo-text.png" alt="" />
20603    <img src="/images/logo/logo-text.png" alt="" />
20604    <img src="/images/logo/logo-text.png" alt="" />
20605    <img src="/images/logo/logo-text.png" alt="" />
20606    <img src="/images/logo/logo-text.png" alt="" />
20607    <img src="/images/logo/logo-text.png" alt="" />
20608    <img src="/images/logo/logo-text.png" alt="" />
20609  </div>
20610  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20611  {% if let Some(banner) = report_header_footer %}
20612  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
20613  {% endif %}
20614  <div class="top-nav">
20615    <div class="top-nav-inner">
20616      <a class="brand" href="/">
20617        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20618        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
20619      </a>
20620      <div class="nav-project-slot">
20621        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
20622      </div>
20623      <div class="nav-status">
20624        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
20625        <div class="nav-dropdown">
20626          <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>
20627          <div class="nav-dropdown-menu">
20628            <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>
20629          </div>
20630        </div>
20631        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
20632        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20633        <div class="nav-dropdown">
20634          <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>
20635          <div class="nav-dropdown-menu">
20636            <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>
20637          </div>
20638        </div>
20639        <div class="server-status-wrap" id="server-status-wrap">
20640          <div class="nav-pill server-online-pill" id="server-status-pill">
20641            <span class="status-dot" id="status-dot"></span>
20642            <span id="server-status-label">Server</span>
20643            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20644          </div>
20645          <div class="server-status-tip">
20646            OxideSLOC is running — accessible on your network.
20647            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20648          </div>
20649        </div>
20650        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20651          <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>
20652        </button>
20653        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
20654          <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>
20655          <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>
20656        </button>
20657      </div>
20658    </div>
20659  </div>
20660
20661  <div class="page">
20662    <section class="hero">
20663      <div class="hero-top">
20664        <div>
20665          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
20666            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
20667            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
20668            <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>
20669          </div>
20670        </div>
20671        <div class="hero-quick-actions">
20672          {% if server_mode %}
20673          <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>
20674          {% else %}
20675          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
20676          {% endif %}
20677          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
20678          {% if !server_mode %}
20679          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
20680          {% endif %}
20681          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
20682          <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>
20683        </div>
20684      </div>
20685
20686      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
20687      <div class="run-id-row">
20688        <span class="run-id-chip" data-copy="{{ run_id }}">
20689          <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>
20690          <span class="run-id-chip-value">{{ run_id }}</span>
20691          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
20692        </span>
20693        {% match git_commit_long %}
20694          {% when Some with (long_sha) %}
20695          {% match git_commit_url %}
20696            {% when Some with (commit_url) %}
20697            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
20698              <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>
20699              <span class="run-id-chip-value">{{ long_sha }}</span>
20700              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
20701            </a>
20702            {% when None %}
20703            <span class="run-id-chip" data-copy="{{ long_sha }}">
20704              <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>
20705              <span class="run-id-chip-value">{{ long_sha }}</span>
20706              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
20707            </span>
20708          {% endmatch %}
20709          {% when None %}
20710          <span class="run-id-chip muted-chip">
20711            <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>
20712            <span class="run-id-chip-value">Not detected</span>
20713            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
20714          </span>
20715        {% endmatch %}
20716        {% match git_branch %}
20717          {% when Some with (branch) %}
20718          {% match git_branch_url %}
20719            {% when Some with (branch_url) %}
20720            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
20721              <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>
20722              <span class="run-id-chip-value">{{ branch }}</span>
20723              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
20724            </a>
20725            {% when None %}
20726            <span class="run-id-chip">
20727              <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>
20728              <span class="run-id-chip-value">{{ branch }}</span>
20729              <span class="chip-tooltip">Git branch active at scan time</span>
20730            </span>
20731          {% endmatch %}
20732          {% when None %}
20733          <span class="run-id-chip muted-chip">
20734            <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>
20735            <span class="run-id-chip-value">Not detected</span>
20736            <span class="chip-tooltip">No Git branch was found for this scan</span>
20737          </span>
20738        {% endmatch %}
20739        {% match git_author %}
20740          {% when Some with (author) %}
20741          <span class="run-id-chip" data-author="{{ author }}">
20742            <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>
20743            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
20744            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
20745          </span>
20746          {% when None %}
20747          <span class="run-id-chip muted-chip">
20748            <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>
20749            <span class="run-id-chip-value">Not detected</span>
20750            <span class="chip-tooltip">No commit author was found for this scan</span>
20751          </span>
20752        {% endmatch %}
20753      </div>
20754
20755      <!-- Scan metadata row -->
20756      <div class="meta">
20757        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
20758        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
20759        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
20760        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
20761        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
20762      </div>
20763
20764      <!-- All summary stat chips in one unified strip (8 columns) -->
20765      <div class="summary-strip">
20766        <div class="stat-chip" data-raw="{{ physical_lines }}">
20767          <div class="stat-chip-label">Physical lines</div>
20768          <div class="stat-chip-val">{{ physical_lines }}</div>
20769          <div class="stat-chip-exact"></div>
20770          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
20771        </div>
20772        <div class="stat-chip" data-raw="{{ code_lines }}">
20773          <div class="stat-chip-label">Code</div>
20774          <div class="stat-chip-val">{{ code_lines }}</div>
20775          <div class="stat-chip-exact"></div>
20776          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
20777        </div>
20778        <div class="stat-chip" data-raw="{{ comment_lines }}">
20779          <div class="stat-chip-label">Comments</div>
20780          <div class="stat-chip-val">{{ comment_lines }}</div>
20781          <div class="stat-chip-exact"></div>
20782          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
20783        </div>
20784        <div class="stat-chip" data-raw="{{ blank_lines }}">
20785          <div class="stat-chip-label">Blank</div>
20786          <div class="stat-chip-val">{{ blank_lines }}</div>
20787          <div class="stat-chip-exact"></div>
20788          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
20789        </div>
20790        <div class="stat-chip" data-raw="{{ mixed_lines }}">
20791          <div class="stat-chip-label">Mixed separate</div>
20792          <div class="stat-chip-val">{{ mixed_lines }}</div>
20793          <div class="stat-chip-exact"></div>
20794          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
20795        </div>
20796        <div class="stat-chip" data-raw="{{ functions }}">
20797          <div class="stat-chip-label">Functions</div>
20798          <div class="stat-chip-val">{{ functions }}</div>
20799          <div class="stat-chip-exact"></div>
20800          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
20801        </div>
20802        <div class="stat-chip" data-raw="{{ classes }}">
20803          <div class="stat-chip-label">Classes / Types</div>
20804          <div class="stat-chip-val">{{ classes }}</div>
20805          <div class="stat-chip-exact"></div>
20806          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
20807        </div>
20808        <div class="stat-chip" data-raw="{{ variables }}">
20809          <div class="stat-chip-label">Variables</div>
20810          <div class="stat-chip-val">{{ variables }}</div>
20811          <div class="stat-chip-exact"></div>
20812          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
20813        </div>
20814        <div class="stat-chip" data-raw="{{ imports }}">
20815          <div class="stat-chip-label">Imports</div>
20816          <div class="stat-chip-val">{{ imports }}</div>
20817          <div class="stat-chip-exact"></div>
20818          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
20819        </div>
20820        <div class="stat-chip" data-raw="{{ test_count }}">
20821          <div class="stat-chip-label">Tests</div>
20822          <div class="stat-chip-val">{{ test_count }}</div>
20823          <div class="stat-chip-exact"></div>
20824          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
20825        </div>
20826        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
20827          <div class="stat-chip-label">Code density</div>
20828          <div class="stat-chip-val stat-chip-density-val">—</div>
20829          <div class="stat-chip-exact"></div>
20830          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
20831        </div>
20832        <div class="stat-chip" data-raw="{{ files_analyzed }}">
20833          <div class="stat-chip-label">Files analyzed</div>
20834          <div class="stat-chip-val">{{ files_analyzed }}</div>
20835          <div class="stat-chip-exact"></div>
20836          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
20837        </div>
20838        {% if cyclomatic_complexity > 0 %}
20839        <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 %}>
20840          <div class="stat-chip-label">Complexity score</div>
20841          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
20842          <div class="stat-chip-exact"></div>
20843          <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>
20844        </div>
20845        {% endif %}
20846        {% if let Some(ls) = lsloc %}
20847        <div class="stat-chip" data-raw="{{ ls }}">
20848          <div class="stat-chip-label">Logical SLOC</div>
20849          <div class="stat-chip-val">{{ ls }}</div>
20850          <div class="stat-chip-exact"></div>
20851          <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>
20852        </div>
20853        {% endif %}
20854        {% if uloc > 0 %}
20855        <div class="stat-chip" data-raw="{{ uloc }}">
20856          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
20857          <div class="stat-chip-val">{{ uloc }}</div>
20858          <div class="stat-chip-exact"></div>
20859          <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>
20860        </div>
20861        {% endif %}
20862        {% if uloc > 0 && dryness_pct_str != "" %}
20863        <div class="stat-chip">
20864          <div class="stat-chip-label">DRYness</div>
20865          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
20866          <div class="stat-chip-exact"></div>
20867          <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>
20868        </div>
20869        {% endif %}
20870        {% if duplicate_group_count > 0 %}
20871        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
20872          <div class="stat-chip-label">Duplicate groups</div>
20873          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
20874          <div class="stat-chip-exact"></div>
20875          <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>
20876        </div>
20877        {% endif %}
20878      </div>
20879
20880      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
20881      <div class="compare-banner">
20882        <div class="compare-banner-body">
20883          <div class="compare-banner-top">
20884          <div class="compare-banner-meta">
20885            <span class="compare-label">Previous scan</span>
20886            <span class="compare-ts">{{ prev_ts }}</span>
20887            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
20888            {% if let Some(prev_code) = prev_run_code_lines %}
20889            <div class="compare-banner-stats" style="margin-top:4px;">
20890              <span>Code before: <strong>{{ prev_code }}</strong></span>
20891              <span class="compare-arrow">→</span>
20892              <span>Code now: <strong>{{ code_lines }}</strong></span>
20893              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
20894              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
20895            </div>
20896            {% endif %}
20897          </div>
20898          {% if delta_lines_added.is_some() %}
20899          <div class="delta-cards-inline">
20900            <div class="delta-card-inline">
20901              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
20902              <div class="delta-card-lbl">lines added</div>
20903              <div class="delta-card-tip">Code lines added since the previous scan</div>
20904            </div>
20905            <div class="delta-card-inline">
20906              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
20907              <div class="delta-card-lbl">lines removed</div>
20908              <div class="delta-card-tip">Code lines removed since the previous scan</div>
20909            </div>
20910            <div class="delta-card-inline">
20911              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
20912              <div class="delta-card-lbl">unmodified lines</div>
20913              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
20914            </div>
20915            <div class="delta-card-inline">
20916              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
20917              <div class="delta-card-lbl">files modified</div>
20918              <div class="delta-card-tip">Files with at least one line changed</div>
20919            </div>
20920            <div class="delta-card-inline">
20921              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
20922              <div class="delta-card-lbl">files added</div>
20923              <div class="delta-card-tip">New files added since the previous scan</div>
20924            </div>
20925            <div class="delta-card-inline">
20926              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
20927              <div class="delta-card-lbl">files removed</div>
20928              <div class="delta-card-tip">Files deleted since the previous scan</div>
20929            </div>
20930            <div class="delta-card-inline">
20931              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
20932              <div class="delta-card-lbl">files unchanged</div>
20933              <div class="delta-card-tip">Files with no changes since the previous scan</div>
20934            </div>
20935          </div>
20936          {% else %}
20937          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
20938            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
20939          </p>
20940          {% endif %}
20941          </div>
20942          <div class="compare-banner-actions">
20943            <div class="compare-banner-actions-left">
20944              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
20945              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
20946            </div>
20947            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
20948          </div>
20949        </div>
20950      </div>
20951      {% endif %}{% endif %}
20952
20953      <div class="action-grid">
20954        <div class="action-card">
20955          <h3>HTML report</h3>
20956          <div class="action-buttons">
20957            {% match html_url %}
20958              {% when Some with (url) %}
20959                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
20960              {% when None %}{% endmatch %}
20961            {% match html_download_url %}
20962              {% when Some with (url) %}
20963                <a class="button secondary" href="{{ url }}">Download HTML</a>
20964              {% when None %}{% endmatch %}
20965            {% match html_path %}
20966              {% when Some with (_path) %}{% when None %}{% endmatch %}
20967            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
20968          </div>
20969        </div>
20970        <div class="action-card">
20971          <h3>PDF report</h3>
20972          <div class="action-buttons">
20973            {% match pdf_url %}
20974              {% when Some with (url) %}
20975                {% if pdf_generating %}
20976                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
20977                    <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>
20978                    Generating PDF…
20979                  </button>
20980                {% else %}
20981                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
20982                {% endif %}
20983              {% when None %}
20984                {% match html_url %}
20985                  {% when Some with (_hurl) %}
20986                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
20987                    <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>
20988                  {% when None %}
20989                    <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;">
20990                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
20991                    </p>
20992                {% endmatch %}
20993            {% endmatch %}
20994            {% match pdf_download_url %}
20995              {% when Some with (url) %}
20996                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
20997              {% when None %}{% endmatch %}
20998            {% match pdf_url %}
20999              {% when Some with (_) %}
21000                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
21001              {% when None %}{% endmatch %}
21002          </div>
21003        </div>
21004        <div class="action-card">
21005          <h3>JSON result</h3>
21006          <div class="action-buttons">
21007            {% match json_url %}
21008              {% when Some with (url) %}
21009                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
21010              {% when None %}{% endmatch %}
21011            {% match json_download_url %}
21012              {% when Some with (url) %}
21013                <a class="button secondary" href="{{ url }}">Download JSON</a>
21014              {% when None %}{% endmatch %}
21015            {% match json_path %}
21016              {% when Some with (_path) %}
21017                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
21018              {% when None %}
21019                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
21020              {% endmatch %}
21021          </div>
21022        </div>
21023        <div class="action-card">
21024          <h3>Scan config</h3>
21025          <div class="action-buttons">
21026            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
21027            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
21028            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
21029          </div>
21030        </div>
21031        {% if confluence_configured %}
21032        <div class="action-card" id="confluenceCard">
21033          <h3>Confluence</h3>
21034          <div class="action-buttons">
21035            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
21036            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
21037          </div>
21038          <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>
21039        </div>
21040        {% endif %}
21041      </div>
21042      {% if confluence_configured %}
21043      <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;">
21044        <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);">
21045          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
21046          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
21047          <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;">
21048          <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>
21049          <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;">
21050          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
21051          <div style="display:flex;gap:10px;justify-content:flex-end;">
21052            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
21053            <button class="button" id="confSubmitBtn" type="button">Post</button>
21054          </div>
21055        </div>
21056      </div>
21057      {% endif %}
21058      <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;">
21059        <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);">
21060          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
21061          <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>
21062          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
21063          <div style="display:flex;gap:18px;justify-content:flex-end;">
21064            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
21065            <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>
21066          </div>
21067        </div>
21068      </div>
21069      {% if !submodule_rows.is_empty() %}
21070      <div class="submodule-panel">
21071        <div class="toolbar-row">
21072          <div>
21073            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
21074            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
21075          </div>
21076          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
21077        </div>
21078        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
21079        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
21080          <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>
21081          <thead>
21082            <tr>
21083              <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>
21084              <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>
21085              <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>
21086              <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>
21087              <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>
21088              <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>
21089              <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>
21090              <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>
21091            </tr>
21092          </thead>
21093          <tbody>
21094            {% for row in submodule_rows %}
21095            <tr>
21096              <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>
21097              <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>
21098              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
21099              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
21100              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
21101              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
21102              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
21103              <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>
21104            </tr>
21105            {% endfor %}
21106          </tbody>
21107        </table>
21108        </div>
21109      </div>
21110      {% endif %}
21111
21112      <div class="metrics-tables-stack">
21113
21114        <div class="metrics-table-wrap">
21115          <div class="metrics-table-title">Files</div>
21116          <table class="metrics-table">
21117            <thead>
21118              <tr>
21119                <th>Metric</th>
21120                <th>This Run</th>
21121                <th>Previous</th>
21122                <th>Change</th>
21123              </tr>
21124            </thead>
21125            <tbody>
21126              <tr>
21127                <td>Files analyzed</td>
21128                <td class="mt-val-large">{{ files_analyzed }}</td>
21129                <td>{{ prev_fa_str }}</td>
21130                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
21131              </tr>
21132              <tr>
21133                <td>Files skipped</td>
21134                <td>{{ files_skipped }}</td>
21135                <td>{{ prev_fs_str }}</td>
21136                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
21137              </tr>
21138              <tr>
21139                <td>Files modified</td>
21140                <td class="mt-val-na">—</td>
21141                <td class="mt-val-na">—</td>
21142                <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>
21143              </tr>
21144              <tr>
21145                <td>Files unchanged</td>
21146                <td class="mt-val-na">—</td>
21147                <td class="mt-val-na">—</td>
21148                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21149              </tr>
21150            </tbody>
21151          </table>
21152        </div>
21153
21154        <div class="metrics-table-wrap">
21155          <div class="metrics-table-title">Line Counts</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>Physical lines</td>
21168                <td class="mt-val-large">{{ physical_lines }}</td>
21169                <td>{{ prev_pl_str }}</td>
21170                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
21171              </tr>
21172              <tr>
21173                <td>Code lines</td>
21174                <td class="mt-val-large">{{ code_lines }}</td>
21175                <td>{{ prev_cl_str }}</td>
21176                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
21177              </tr>
21178              <tr>
21179                <td>Comment lines</td>
21180                <td>{{ comment_lines }}</td>
21181                <td>{{ prev_cml_str }}</td>
21182                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
21183              </tr>
21184              <tr>
21185                <td>Blank lines</td>
21186                <td>{{ blank_lines }}</td>
21187                <td>{{ prev_bl_str }}</td>
21188                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
21189              </tr>
21190              <tr>
21191                <td>Mixed (separate)</td>
21192                <td>{{ mixed_lines }}</td>
21193                <td class="mt-val-na">—</td>
21194                <td class="mt-val-na">—</td>
21195              </tr>
21196            </tbody>
21197          </table>
21198        </div>
21199
21200        <div class="metrics-tables-lower">
21201          <div class="metrics-table-wrap">
21202            <div class="metrics-table-title">Code Structure</div>
21203            <table class="metrics-table">
21204              <thead>
21205                <tr>
21206                  <th>Metric</th>
21207                  <th>This Run</th>
21208                </tr>
21209              </thead>
21210              <tbody>
21211                <tr>
21212                  <td>Functions</td>
21213                  <td>{{ functions }}</td>
21214                </tr>
21215                <tr>
21216                  <td>Classes / Types</td>
21217                  <td>{{ classes }}</td>
21218                </tr>
21219                <tr>
21220                  <td>Variables</td>
21221                  <td>{{ variables }}</td>
21222                </tr>
21223                <tr>
21224                  <td>Imports</td>
21225                  <td>{{ imports }}</td>
21226                </tr>
21227              </tbody>
21228            </table>
21229          </div>
21230
21231          <div class="metrics-table-wrap">
21232            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
21233            <table class="metrics-table">
21234              <thead>
21235                <tr>
21236                  <th>Metric</th>
21237                  <th>Change</th>
21238                </tr>
21239              </thead>
21240              <tbody>
21241                <tr>
21242                  <td>Lines added</td>
21243                  <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>
21244                </tr>
21245                <tr>
21246                  <td>Lines removed</td>
21247                  <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>
21248                </tr>
21249                <tr>
21250                  <td>Lines modified (net)</td>
21251                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
21252                </tr>
21253                <tr>
21254                  <td>Lines unmodified</td>
21255                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21256                </tr>
21257              </tbody>
21258            </table>
21259          </div>
21260        </div>
21261
21262      </div>
21263
21264      <div class="path-list">
21265        <div class="path-item">
21266          <div class="path-item-label">Project path</div>
21267          <code>{{ project_path }}</code>
21268        </div>
21269        <div class="path-item">
21270          <div class="path-item-label">Git branch</div>
21271          {% if let Some(branch) = git_branch %}
21272          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
21273          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
21274          {% else %}
21275          <code style="color:var(--muted)">—</code>
21276          {% endif %}
21277        </div>
21278        <div class="path-item">
21279          <div class="path-item-label">Output folder</div>
21280          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
21281        </div>
21282        <div class="path-item">
21283          <div class="path-item-label">Run ID</div>
21284          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
21285            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
21286            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
21287          </div>
21288        </div>
21289      </div>
21290    </section>
21291
21292    {% if has_cocomo %}
21293    <div class="cocomo-box" style="margin-top:24px;">
21294      <div class="cocomo-box-head">
21295        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
21296        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
21297          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
21298          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
21299        </span>
21300      </div>
21301      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
21302        <div class="stat-chip">
21303          <div class="stat-chip-label">Person-months</div>
21304          <div class="stat-chip-val">{{ cocomo_effort_str }}</div>
21305          <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>
21306        </div>
21307        <div class="stat-chip">
21308          <div class="stat-chip-label">Schedule (months)</div>
21309          <div class="stat-chip-val">{{ cocomo_duration_str }}</div>
21310          <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>
21311        </div>
21312        <div class="stat-chip">
21313          <div class="stat-chip-label">Avg. Team Size</div>
21314          <div class="stat-chip-val">{{ cocomo_staff_str }}</div>
21315          <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>
21316        </div>
21317        <div class="stat-chip">
21318          <div class="stat-chip-label">Input KSLOC</div>
21319          <div class="stat-chip-val">{{ cocomo_ksloc_str }}K</div>
21320          <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>
21321        </div>
21322      </div>
21323      <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>
21324    </div>
21325    {% endif %}
21326
21327    <div class="section-pair">
21328    <section class="panel">
21329        <div class="toolbar-row">
21330          <div>
21331            <h2>Language breakdown</h2>
21332            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
21333          </div>
21334          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21335        </div>
21336        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
21337    </section>
21338
21339    <section class="panel r-chart-section">
21340      <div class="toolbar-row" style="margin-bottom:16px;">
21341        <div>
21342          <h2>Visualizations</h2>
21343          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
21344        </div>
21345      </div>
21346
21347      <div class="r-viz-grid">
21348        <div class="r-viz-card">
21349          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21350            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
21351            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21352          </div>
21353          <div class="r-chart-tab-bar">
21354            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
21355            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
21356          </div>
21357          <div class="r-chart-container" id="r-composition-chart"></div>
21358        </div>
21359        <div class="r-viz-card">
21360          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21361            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
21362            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21363          </div>
21364          <div class="r-chart-container" id="r-scatter-chart"></div>
21365        </div>
21366        {% if has_semantic_data %}
21367        <div class="r-viz-card">
21368          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21369            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
21370            <select class="r-chart-select" id="r-semantic-metric">
21371              <option value="functions">Functions</option>
21372              <option value="classes">Classes</option>
21373              <option value="variables">Variables</option>
21374              <option value="imports">Imports</option>
21375              <option value="tests">Tests</option>
21376            </select>
21377            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21378          </div>
21379          <div class="r-chart-container" id="r-semantic-chart"></div>
21380        </div>
21381        {% endif %}
21382        <div class="r-viz-card">
21383          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21384            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
21385            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21386          </div>
21387          <div class="r-chart-container" id="r-density-chart"></div>
21388        </div>
21389        <div class="r-viz-card">
21390          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21391            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
21392            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21393          </div>
21394          <div class="r-chart-container" id="r-avglines-chart"></div>
21395        </div>
21396        <div class="r-viz-card">
21397          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
21398            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
21399            <select class="r-chart-select" id="r-sub-metric">
21400              <option value="code">Code Lines</option>
21401              <option value="comment">Comments</option>
21402              <option value="blank">Blank Lines</option>
21403              <option value="physical">Physical Lines</option>
21404              <option value="files">Files</option>
21405            </select>
21406            <select class="r-chart-select" id="r-sub-sort">
21407              <option value="desc">Value ↓</option>
21408              <option value="asc">Value ↑</option>
21409              <option value="name">Name A→Z</option>
21410            </select>
21411            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
21412          </div>
21413          <div class="r-chart-container" id="r-submodule-chart"></div>
21414        </div>
21415      </div>
21416
21417    </section>
21418    </div>
21419
21420  </div>
21421
21422  <div id="r-tt" aria-hidden="true"></div>
21423
21424  <script nonce="{{ csp_nonce }}">
21425    (function () {
21426      var body = document.body;
21427      var themeToggle = document.getElementById('theme-toggle');
21428      var storageKey = 'oxide-sloc-theme';
21429
21430      function applyTheme(theme) {
21431        body.classList.toggle('dark-theme', theme === 'dark');
21432      }
21433
21434      function loadSavedTheme() {
21435        try {
21436          var saved = localStorage.getItem(storageKey);
21437          if (saved === 'dark' || saved === 'light') {
21438            applyTheme(saved);
21439          }
21440        } catch (e) {}
21441      }
21442
21443      if (themeToggle) {
21444        themeToggle.addEventListener('click', function () {
21445          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
21446          applyTheme(nextTheme);
21447          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
21448        });
21449      }
21450
21451      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
21452        button.addEventListener('click', function () {
21453          var value = button.getAttribute('data-copy-value') || '';
21454          if (!value) return;
21455          var originalText = button.textContent;
21456          function flashSuccess() {
21457            button.textContent = 'Copied!';
21458            setTimeout(function () { button.textContent = originalText; }, 1800);
21459          }
21460          function flashFail() {
21461            button.textContent = 'Copy failed';
21462            setTimeout(function () { button.textContent = originalText; }, 2000);
21463          }
21464          if (navigator.clipboard && navigator.clipboard.writeText) {
21465            navigator.clipboard.writeText(value).then(flashSuccess, function () {
21466              fallbackCopy(value, flashSuccess, flashFail);
21467            });
21468          } else {
21469            fallbackCopy(value, flashSuccess, flashFail);
21470          }
21471        });
21472      });
21473      function fallbackCopy(text, onSuccess, onFail) {
21474        try {
21475          var ta = document.createElement('textarea');
21476          ta.value = text;
21477          ta.style.position = 'fixed';
21478          ta.style.top = '-9999px';
21479          ta.style.left = '-9999px';
21480          document.body.appendChild(ta);
21481          ta.focus();
21482          ta.select();
21483          var ok = document.execCommand('copy');
21484          document.body.removeChild(ta);
21485          if (ok) { onSuccess(); } else { onFail(); }
21486        } catch (e) { onFail(); }
21487      }
21488
21489      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21490        btn.addEventListener('click', function () {
21491          var folder = btn.getAttribute('data-folder') || '';
21492          if (!folder) return;
21493          var orig = btn.textContent;
21494          fetch('/open-path?path=' + encodeURIComponent(folder))
21495            .then(function (r) { return r.json(); })
21496            .then(function (d) {
21497              if (d && d.server_mode_disabled) {
21498                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21499              } else if (d && d.ok) {
21500                btn.textContent = 'Opened!';
21501                setTimeout(function () { btn.textContent = orig; }, 1800);
21502              }
21503            })
21504            .catch(function () {
21505              btn.textContent = 'Failed';
21506              setTimeout(function () { btn.textContent = orig; }, 2000);
21507            });
21508        });
21509      });
21510
21511      loadSavedTheme();
21512
21513      // ── Compact number formatting for stat chips ──────────────────────────
21514      (function(){
21515        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();}
21516        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
21517          var raw=parseInt(chip.getAttribute('data-raw'),10);
21518          if(isNaN(raw))return;
21519          var valEl=chip.querySelector('.stat-chip-val');
21520          if(valEl)valEl.textContent=fmt(raw);
21521          var exactEl=chip.querySelector('.stat-chip-exact');
21522          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
21523        });
21524        // Code density chip
21525        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
21526          var code=parseInt(chip.getAttribute('data-code'),10);
21527          var phys=parseInt(chip.getAttribute('data-physical'),10);
21528          if(isNaN(code)||isNaN(phys)||phys===0)return;
21529          var pct=(code/phys*100).toFixed(1)+'%';
21530          var valEl=chip.querySelector('.stat-chip-val');
21531          if(valEl)valEl.textContent=pct;
21532        });
21533        // Populate author handle from data-author attribute
21534        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
21535          var author=chip.getAttribute('data-author');
21536          var el=chip.querySelector('.author-handle');
21537          if(el)el.textContent='/'+author.replace(/\s+/g,'');
21538        });
21539        // Click-to-copy on run-id-chip elements
21540        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
21541          chip.addEventListener('click',function(){
21542            var val=chip.getAttribute('data-copy');
21543            if(!val)return;
21544            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
21545            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);}
21546            chip.classList.add('chip-copied-flash');
21547            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
21548          });
21549        });
21550      })();
21551
21552      // ── Shared tooltip for all result-page charts ─────────────────────────
21553      var rTT=(function(){
21554        var el=document.getElementById('r-tt');
21555        if(!el)return{s:function(){},h:function(){},m:function(){}};
21556        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
21557        function hide(){el.style.display='none';}
21558        function move(e){
21559          var x=e.clientX+16,y=e.clientY-12;
21560          var r=el.getBoundingClientRect();
21561          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
21562          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
21563          el.style.left=x+'px';el.style.top=y+'px';
21564        }
21565        return{s:show,h:hide,m:move};
21566      })();
21567      window.rTT=rTT;
21568
21569      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
21570      (function(){
21571        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21572        document.addEventListener('mouseover',function(e){
21573          var t=e.target;
21574          while(t&&t.getAttribute){
21575            var l=t.getAttribute('data-ttl');
21576            if(l!==null){
21577              var v=t.getAttribute('data-ttv')||'';
21578              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
21579              return;
21580            }
21581            t=t.parentNode;
21582          }
21583        });
21584        document.addEventListener('mouseout',function(e){
21585          var t=e.target;
21586          while(t&&t.getAttribute){
21587            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
21588            t=t.parentNode;
21589          }
21590        });
21591        document.addEventListener('mousemove',function(e){
21592          var el=document.getElementById('r-tt');
21593          if(el&&el.style.display!=='none')rTT.m(e);
21594        });
21595        window.addEventListener('blur',function(){rTT.h();});
21596        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
21597      })();
21598
21599      // ── Language overview charts ───────────────────────────────────────────
21600      (function(){
21601        var D={{ lang_chart_json|safe }};
21602        if(!D||!D.length)return;
21603        var el=document.getElementById('result-lang-charts');
21604        if(!el)return;
21605        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21606        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
21607        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21608        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();}
21609        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21610        function px(n){return Math.round(n);}
21611        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+'"';}
21612        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
21613
21614        // Donut chart — height matches the stacked-bar chart so both panels align
21615        var rHb_d=28;
21616        var DH=Math.max(220,D.length*rHb_d+32);
21617        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
21618        var legX=204,DW=360;
21619        var legCount=D.length;
21620        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
21621        var legYStart=Math.round((DH-legCount*legSpacing)/2);
21622        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">';
21623        if(D.length===1){
21624          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
21625          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+'"/>';
21626        } else {
21627          var ang=-Math.PI/2;
21628          D.forEach(function(d,i){
21629            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21630            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
21631            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
21632            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
21633            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
21634            var pct=Math.round(d.code/tot*100);
21635            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"/>';
21636            ang+=sw;
21637          });
21638        }
21639        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
21640        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
21641        D.forEach(function(d,i){
21642          var ly=legYStart+i*legSpacing;
21643          var pctL=Math.round(d.code/tot*100);
21644          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
21645          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
21646          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
21647          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
21648          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
21649          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
21650          ds+='</g>';
21651        });
21652        ds+='</svg>';
21653
21654        // Horizontal stacked-bar chart — fills container width
21655        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
21656        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
21657        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">';
21658        D.forEach(function(d,i){
21659          var y=6+i*rHb,x=LW;
21660          var phys=d.physical||d.code+d.comments+d.blanks;
21661          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
21662          bs+='<g class="lang-bar-row">';
21663          bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
21664          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>';
21665          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;
21666          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;
21667          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"/>';
21668          bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
21669          bs+='</g>';
21670        });
21671        var ly=SH-14;
21672        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
21673        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
21674        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
21675        var totAll=totC+totCm+totBl||1;
21676        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
21677        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
21678        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
21679        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
21680        bs+='<g data-kind="code" style="cursor:pointer;">'
21681          +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
21682          +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
21683          +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
21684          +'</g>';
21685        bs+='<g data-kind="comment" style="cursor:pointer;">'
21686          +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
21687          +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
21688          +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
21689          +'</g>';
21690        bs+='<g data-kind="blank" style="cursor:pointer;">'
21691          +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
21692          +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
21693          +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
21694          +'</g>';
21695        bs+='</svg>';
21696        el.innerHTML='<div class="r-lang-overview">'+
21697          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
21698          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
21699        '</div>';
21700        function wireDonutLegend(svg){
21701          if(!svg)return;
21702          var paths=svg.querySelectorAll('path[data-lang]');
21703          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';}}}
21704          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
21705          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;}});
21706          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
21707        }
21708        function wireMixLegend(svg){
21709          if(!svg)return;
21710          var legGs=svg.querySelectorAll('g[data-kind]');
21711          var allRects=svg.querySelectorAll('rect[data-kind]');
21712          if(!legGs.length)return;
21713          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';}}
21714          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='';}}
21715          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]);}
21716        }
21717        wireDonutLegend(el.querySelector('svg'));
21718        wireMixLegend(el.querySelectorAll('svg')[1]);
21719
21720        // ── Language breakdown Full View expand ─────────────────────────────────
21721        var langOvBtn=document.getElementById('result-lang-overview-expand');
21722        if(langOvBtn){langOvBtn.addEventListener('click',function(){
21723          var src=document.getElementById('result-lang-charts');
21724          if(!src)return;
21725          var overlay=document.createElement('div');
21726          overlay.className='r-chart-modal-overlay';
21727          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>';
21728          document.body.appendChild(overlay);
21729          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21730          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21731          var wrap=document.getElementById('result-lang-overview-modal-wrap');
21732          if(wrap){
21733            wrap.innerHTML=src.innerHTML;
21734            var svgs=wrap.querySelectorAll('svg');
21735            for(var i=0;i<svgs.length;i++){
21736              svgs[i].removeAttribute('width');
21737              svgs[i].removeAttribute('height');
21738              svgs[i].style.cssText='display:block;width:100%;height:auto;';
21739            }
21740            var ov=wrap.querySelector('.r-lang-overview');
21741            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
21742            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
21743            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
21744            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
21745            wireDonutLegend(wrap.querySelector('svg'));
21746            wireMixLegend(wrap.querySelectorAll('svg')[1]);
21747            requestAnimationFrame(function(){
21748              var ss=wrap.querySelectorAll('svg');
21749              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%;';}}
21750            });
21751          }
21752        });}
21753      })();
21754
21755      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
21756      (function(){
21757        var LANG_D={{ lang_chart_json|safe }};
21758        var SCAT_D={{ scatter_chart_json|safe }};
21759        var SEM_D={{ semantic_chart_json|safe }};
21760        var SUB_D={{ submodule_chart_json|safe }};
21761        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
21762        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21763        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();}
21764        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21765        function px(n){return Math.round(n);}
21766        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+'"';}
21767
21768        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
21769        function renderCompositionInEl(el,mode,shOvr){
21770          if(!el||!LANG_D||!LANG_D.length)return;
21771          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21772          var LW=110,SH=shOvr||224;
21773          var svgW=Math.max(320,el.offsetWidth||480);
21774          var BW=Math.max(120,svgW-LW-80);
21775          var legendH=24,topPad=4;
21776          var n=LANG_D.length||1;
21777          var rowTotal=Math.floor((SH-legendH-topPad)/n);
21778          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
21779          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">';
21780          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
21781          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
21782          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
21783          var totAll2=totC2+totCm2+totBl2||1;
21784          if(mode==='pct'){
21785            LANG_D.forEach(function(d,i){
21786              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
21787              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
21788              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21789              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>';
21790              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;
21791              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;
21792              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+'"/>';
21793              var pct=Math.round((d.code||0)/tot2*100);
21794              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>';
21795            });
21796          } else {
21797            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
21798            LANG_D.forEach(function(d,i){
21799              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
21800              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21801              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>';
21802              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;
21803              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;
21804              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+'"/>';
21805              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>';
21806            });
21807          }
21808          var ly=SH-legendH+4;
21809          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
21810          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
21811          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
21812          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
21813          s+='<g data-kind="code" style="cursor:pointer;">'
21814            +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
21815            +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
21816            +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
21817            +'</g>';
21818          s+='<g data-kind="comment" style="cursor:pointer;">'
21819            +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
21820            +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
21821            +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
21822            +'</g>';
21823          s+='<g data-kind="blank" style="cursor:pointer;">'
21824            +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
21825            +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
21826            +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
21827            +'</g>';
21828          s+='</svg>';
21829          el.innerHTML=s;
21830          wireMixLegendEl(el);
21831        }
21832        function wireMixLegendEl(container){
21833          var svg=container&&container.querySelector('svg');
21834          if(!svg)return;
21835          var legGs=svg.querySelectorAll('g[data-kind]');
21836          var allRects=svg.querySelectorAll('rect[data-kind]');
21837          if(!legGs.length)return;
21838          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';}}
21839          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='';}}
21840          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]);}
21841        }
21842        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
21843        renderComposition('abs');
21844        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
21845          btn.addEventListener('click',function(){
21846            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
21847            btn.classList.add('active');
21848            renderComposition(btn.getAttribute('data-rcomp'));
21849          });
21850        });
21851
21852        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
21853        function renderScatterInEl(el,hOvr){
21854          if(!el||!SCAT_D||!SCAT_D.length)return;
21855          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
21856          var W=Math.max(320,el.offsetWidth||480);
21857          var cW=W-PL-PR,cH=H-PT-PB;
21858          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
21859          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
21860          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
21861          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">';
21862          [0,0.25,0.5,0.75,1].forEach(function(t){
21863            var y=PT+cH*(1-t);
21864            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21865            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>';
21866          });
21867          [0,0.25,0.5,0.75,1].forEach(function(t){
21868            var x=PL+cW*t;
21869            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21870            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>';
21871          });
21872          SCAT_D.forEach(function(d,i){
21873            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
21874            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
21875            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"/>';
21876            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>';
21877          });
21878          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>';
21879          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>';
21880          s+='</svg>';
21881          el.innerHTML=s;
21882        }
21883        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
21884
21885        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
21886        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
21887        // the old vertical column layout on wide containers.
21888        function renderSemanticInEl(el,key,sh){
21889          if(!el||!SEM_D||!SEM_D.length)return;
21890          var n2=SEM_D.length||1;
21891          var LW=112,SH=sh||Math.max(180,n2*28+26);
21892          var svgW=Math.max(320,el.offsetWidth||480);
21893          var BW=Math.max(120,svgW-LW-80);
21894          var topPad=4,botPad=14;
21895          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
21896          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
21897          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
21898          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">';
21899          SEM_D.forEach(function(d,i){
21900            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
21901            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>';
21902            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"/>';
21903            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>';
21904          });
21905          s+='</svg>';
21906          el.innerHTML=s;
21907        }
21908        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
21909        var semSel=document.getElementById('r-semantic-metric');
21910        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
21911        var semExpand=document.getElementById('r-semantic-expand');
21912        if(semExpand){
21913          semExpand.addEventListener('click',function(){
21914            var key=semSel?semSel.value:'functions';
21915            var n=SEM_D.length||1;
21916            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
21917            var modalH=Math.min(Math.max(360,n*38+60),maxH);
21918            var overlay=document.createElement('div');
21919            overlay.className='r-chart-modal-overlay';
21920            var optHtml=
21921              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
21922              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
21923              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
21924              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
21925              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
21926            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>';
21927            document.body.appendChild(overlay);
21928            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21929            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21930            var modalEl=document.getElementById('r-sem-modal-chart');
21931            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
21932            var modalSel=document.getElementById('r-sem-modal-metric');
21933            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
21934          });
21935        }
21936
21937        // ── Expand buttons: re-render charts at large size inside modal ──────────
21938        (function(){
21939          function makeExpandModal(title,mH,subtitle,ctrlHtml){
21940            var overlay=document.createElement('div');
21941            overlay.className='r-chart-modal-overlay';
21942            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
21943            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
21944            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>';
21945            document.body.appendChild(overlay);
21946            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21947            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21948            return overlay.querySelector('.r-expand-modal-chart');
21949          }
21950          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
21951          var compExpandBtn=document.getElementById('r-composition-expand');
21952          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
21953            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
21954            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21955            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
21956              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
21957            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
21958            if(wrap){
21959              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
21960              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
21961                btn.addEventListener('click',function(){
21962                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
21963                  btn.classList.add('active');
21964                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
21965                });
21966              });
21967            }
21968          });}
21969          var scatExpandBtn=document.getElementById('r-scatter-expand');
21970          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
21971            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
21972            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
21973          });}
21974          var densExpandBtn=document.getElementById('r-density-expand');
21975          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
21976            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21977            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
21978            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
21979          });}
21980          var avgExpandBtn=document.getElementById('r-avglines-expand');
21981          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
21982            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
21983            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
21984            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
21985          });}
21986          var subExpandBtn=document.getElementById('r-submodule-expand');
21987          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
21988            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
21989            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
21990            var metCtrl=
21991              '<select class="r-chart-select" id="r-sub-modal-metric">'
21992              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
21993              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
21994              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
21995              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
21996              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
21997              +'</select>';
21998            var sortCtrl=
21999              '<select class="r-chart-select" id="r-sub-modal-sort">'
22000              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
22001              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
22002              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
22003              +'</select>';
22004            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
22005            if(wrap){
22006              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
22007              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
22008              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
22009              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
22010              if(mSub)mSub.addEventListener('change',reRenderSub);
22011              if(mSort)mSort.addEventListener('change',reRenderSub);
22012            }
22013          });}
22014        })();
22015
22016        // ── Comment Density: comments / (code + comments) per language ───────────
22017        function renderDensityInEl(el,shOvr){
22018          if(!el||!LANG_D||!LANG_D.length)return;
22019          var n=LANG_D.length||1;
22020          var LW=112,SH=shOvr||Math.max(180,n*28+26);
22021          var svgW=Math.max(320,el.offsetWidth||480);
22022          var BW=Math.max(120,svgW-LW-80);
22023          var topPad=4,botPad=26;
22024          var rowTotal=Math.floor((SH-topPad-botPad)/n);
22025          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22026          var densities=LANG_D.map(function(d){
22027            var sig=(d.code||0)+(d.comments||0);
22028            return sig>0?(d.comments||0)/sig:0;
22029          });
22030          var maxDen=Math.max.apply(null,densities)||1;
22031          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">';
22032          LANG_D.forEach(function(d,i){
22033            var den=densities[i],bw=den/maxDen*BW;
22034            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22035            var pct=Math.round(den*100);
22036            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>';
22037            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"/>';
22038            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22039            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>';
22040          });
22041          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>';
22042          s+='</svg>';
22043          el.innerHTML=s;
22044        }
22045        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
22046        renderDensity();
22047
22048        // ── Avg Lines per File: code / files per language ─────────────────────
22049        function renderAvgLinesInEl(el,shOvr){
22050          if(!el||!LANG_D||!LANG_D.length)return;
22051          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
22052          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
22053          var n=data.length||1;
22054          var LW=112,SH=shOvr||Math.max(180,n*28+26);
22055          var svgW=Math.max(320,el.offsetWidth||480);
22056          var BW=Math.max(120,svgW-LW-80);
22057          var topPad=4,botPad=26;
22058          var rowTotal=Math.floor((SH-topPad-botPad)/n);
22059          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22060          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
22061          var maxAvg=Math.max.apply(null,avgs)||1;
22062          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">';
22063          data.forEach(function(d,i){
22064            var avg=avgs[i],bw=avg/maxAvg*BW;
22065            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22066            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>';
22067            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"/>';
22068            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22069            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>';
22070          });
22071          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>';
22072          s+='</svg>';
22073          el.innerHTML=s;
22074        }
22075        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
22076        renderAvgLines();
22077
22078        // ── Repository Overview: overall row + per-submodule rows ────────────
22079        function renderSubmoduleInEl(el,key,sort,shOvr){
22080          if(!el)return;
22081          var overall={
22082            name:'Overall',
22083            code:{{ code_lines }},
22084            comment:{{ comment_lines }},
22085            blank:{{ blank_lines }},
22086            physical:{{ physical_lines }},
22087            files:{{ files_analyzed }},
22088            isOverall:true
22089          };
22090          var subs=SUB_D.slice();
22091          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
22092          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
22093          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
22094          var data=[overall].concat(subs);
22095          var rowH=32,bH=22,sepH=subs.length>0?14:0;
22096          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
22097          var svgW=Math.max(320,el.offsetWidth||480);
22098          var LW=116,BW=Math.max(200,svgW-LW-54);
22099          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
22100          var OVERALL_COL='#6b7280';
22101          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">';
22102          var yOff=4;
22103          data.forEach(function(d,i){
22104            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
22105            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
22106            var label=d.name||d.path||'?';
22107            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>';
22108            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
22109            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22110            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>';
22111            yOff+=rowH;
22112            if(d.isOverall&&subs.length>0){
22113              yOff+=sepH;
22114            }
22115          });
22116          s+='</svg>';
22117          el.innerHTML=s;
22118        }
22119        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
22120        var subSel=document.getElementById('r-sub-metric');
22121        var sortSel=document.getElementById('r-sub-sort');
22122        renderSubmodule('code','desc');
22123        if(subSel){
22124          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
22125          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
22126        }
22127
22128        // Equalise heights within each chart row: if one chart in a grid row is taller
22129        // than its neighbour, re-render the shorter one at the taller height so bars fill
22130        // the available vertical space instead of leaving a gap.
22131        function syncRowHeights(){
22132          var avgEl=document.getElementById('r-avglines-chart');
22133          var subEl=document.getElementById('r-submodule-chart');
22134          if(avgEl&&subEl){
22135            var avgSvg=avgEl.querySelector('svg');
22136            var subSvg=subEl.querySelector('svg');
22137            if(avgSvg&&subSvg){
22138              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
22139              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
22140              var key=subSel?subSel.value||'code':'code';
22141              var sort=sortSel?sortSel.value:'desc';
22142              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
22143              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
22144            }
22145          }
22146          var semEl=document.getElementById('r-semantic-chart');
22147          var denEl=document.getElementById('r-density-chart');
22148          if(semEl&&denEl){
22149            var semSvg=semEl.querySelector('svg');
22150            var denSvg=denEl.querySelector('svg');
22151            if(semSvg&&denSvg){
22152              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
22153              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
22154              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
22155              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
22156            }
22157          }
22158        }
22159        syncRowHeights();
22160
22161        // Re-render all SVG charts when the window is resized so bars fill the card.
22162        var _rResizeTimer;
22163        window.addEventListener('resize',function(){
22164          clearTimeout(_rResizeTimer);
22165          _rResizeTimer=setTimeout(function(){
22166            var rcompBtn=document.querySelector('[data-rcomp].active');
22167            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
22168            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
22169            if(semSel)renderSemantic(semSel.value||'functions');
22170            renderDensity();
22171            renderAvgLines();
22172            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
22173            syncRowHeights();
22174          },120);
22175        });
22176      })();
22177
22178      (function randomizeWatermarks() {
22179        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22180        if (!wms.length) return;
22181        var placed = [];
22182        function tooClose(top, left) {
22183          for (var i = 0; i < placed.length; i++) {
22184            var dt = Math.abs(placed[i][0] - top);
22185            var dl = Math.abs(placed[i][1] - left);
22186            if (dt < 20 && dl < 18) return true;
22187          }
22188          return false;
22189        }
22190        function pick(leftBand) {
22191          for (var attempt = 0; attempt < 50; attempt++) {
22192            var top = Math.random() * 85 + 5;
22193            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22194            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22195          }
22196          var top = Math.random() * 85 + 5;
22197          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22198          placed.push([top, left]);
22199          return [top, left];
22200        }
22201        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
22202        var half = Math.floor(wms.length / 2);
22203        wms.forEach(function (img, i) {
22204          var pos = pick(i < half);
22205          var size = Math.floor(Math.random() * 100 + 160);
22206          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
22207          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
22208          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;
22209        });
22210      })();
22211
22212      (function spawnCodeParticles() {
22213        var container = document.getElementById('code-particles');
22214        if (!container) return;
22215        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'];
22216        for (var i = 0; i < 38; i++) {
22217          (function(idx) {
22218            var el = document.createElement('span');
22219            el.className = 'code-particle';
22220            el.textContent = snippets[idx % snippets.length];
22221            var left = Math.random() * 94 + 2;
22222            var top = Math.random() * 88 + 6;
22223            var dur = (Math.random() * 10 + 9).toFixed(1);
22224            var delay = (Math.random() * 18).toFixed(1);
22225            var rot = (Math.random() * 26 - 13).toFixed(1);
22226            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22227            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';
22228            container.appendChild(el);
22229          })(i);
22230        }
22231      })();
22232
22233      {% if pdf_generating %}
22234      // Poll for PDF readiness and swap the disabled button to a live link once done.
22235      (function() {
22236        var openBtn = document.getElementById('pdf-open-btn');
22237        var dlBtn = document.getElementById('pdf-download-btn');
22238        function checkPdf() {
22239          fetch('/api/runs/{{ run_id }}/pdf-status')
22240            .then(function(r) { return r.json(); })
22241            .then(function(d) {
22242              if (d.ready) {
22243                if (openBtn) {
22244                  var a = document.createElement('a');
22245                  a.className = 'button';
22246                  a.id = 'pdf-open-btn';
22247                  a.href = '/runs/pdf/{{ run_id }}';
22248                  a.target = '_blank';
22249                  a.rel = 'noopener';
22250                  a.textContent = 'Open PDF';
22251                  openBtn.replaceWith(a);
22252                }
22253                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
22254              } else {
22255                setTimeout(checkPdf, 3000);
22256              }
22257            })
22258            .catch(function() { setTimeout(checkPdf, 5000); });
22259        }
22260        setTimeout(checkPdf, 3000);
22261      })();
22262      {% endif %}
22263
22264    })();
22265  </script>
22266  <script nonce="{{ csp_nonce }}">
22267  (function(){
22268    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'}];
22269    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);});}
22270    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22271    function init(){
22272      var btn=document.getElementById('settings-btn');if(!btn)return;
22273      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22274      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>';
22275      document.body.appendChild(m);
22276      var g=document.getElementById('scheme-grid');
22277      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);});
22278      var cl=document.getElementById('settings-close');
22279      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);
22280      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');});
22281      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22282      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22283    }
22284    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22285  }());
22286  </script>
22287  <footer class="site-footer">
22288    local code analysis - metrics, history and reports
22289    &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>
22290    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22291    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22292    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22293    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22294  </footer>
22295  {% if confluence_configured %}
22296  <script nonce="{{ csp_nonce }}">
22297  (function() {
22298    var postBtn = document.getElementById('postConfluenceBtn');
22299    var copyBtn = document.getElementById('copyWikiBtn');
22300    var modal   = document.getElementById('confluenceModal');
22301    if (!postBtn || !modal) return;
22302
22303    postBtn.addEventListener('click', function() {
22304      document.getElementById('confStatus').style.display = 'none';
22305      modal.style.display = 'flex';
22306    });
22307    document.getElementById('confCancelBtn').addEventListener('click', function() {
22308      modal.style.display = 'none';
22309    });
22310    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22311
22312    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
22313      var btn = this;
22314      btn.disabled = true;
22315      var status = document.getElementById('confStatus');
22316      status.style.display = 'block';
22317      status.style.background = '#dbeafe';
22318      status.style.color = '#1e40af';
22319      status.textContent = 'Posting to Confluence…';
22320      var resp = await fetch('/api/confluence/post', {
22321        method: 'POST',
22322        headers: { 'Content-Type': 'application/json' },
22323        body: JSON.stringify({
22324          run_id: '{{ run_id }}',
22325          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
22326          report_url: document.getElementById('confReportUrl').value.trim() || null
22327        })
22328      });
22329      var data = await resp.json();
22330      if (data.ok) {
22331        status.style.background = '#dcfce7'; status.style.color = '#166534';
22332        status.textContent = 'Posted! Page ID: ' + data.page_id;
22333      } else {
22334        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22335        status.textContent = 'Error: ' + (data.error || 'Unknown error');
22336      }
22337      btn.disabled = false;
22338    });
22339
22340    if (copyBtn) {
22341      copyBtn.addEventListener('click', async function() {
22342        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
22343        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
22344        var text = await resp.text();
22345        try {
22346          await navigator.clipboard.writeText(text);
22347          var orig = copyBtn.textContent;
22348          copyBtn.textContent = 'Copied!';
22349          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
22350        } catch(e) {
22351          alert('Clipboard write failed — check browser permissions.');
22352        }
22353      });
22354    }
22355  })();
22356  </script>
22357  {% endif %}
22358  <script nonce="{{ csp_nonce }}">
22359  (function() {
22360    var deleteBtn = document.getElementById('delete-run-btn');
22361    var modal     = document.getElementById('delete-run-modal');
22362    var cancelBtn = document.getElementById('delete-run-cancel');
22363    var confirmBtn= document.getElementById('delete-run-confirm');
22364    if (!deleteBtn || !modal) return;
22365    deleteBtn.addEventListener('click', function() {
22366      document.getElementById('delete-run-status').style.display = 'none';
22367      modal.style.display = 'flex';
22368    });
22369    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
22370    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22371    confirmBtn.addEventListener('click', async function() {
22372      confirmBtn.disabled = true;
22373      cancelBtn.disabled = true;
22374      var status = document.getElementById('delete-run-status');
22375      status.style.display = 'block';
22376      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
22377      status.textContent = 'Deleting…';
22378      try {
22379        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
22380        if (resp.status === 204 || resp.ok) {
22381          status.style.background = '#dcfce7'; status.style.color = '#166534';
22382          status.textContent = 'Deleted. Redirecting…';
22383          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
22384        } else {
22385          var d = await resp.json().catch(function(){return {};});
22386          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22387          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
22388          confirmBtn.disabled = false;
22389          cancelBtn.disabled = false;
22390        }
22391      } catch (e) {
22392        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22393        status.textContent = 'Network error: ' + String(e);
22394        confirmBtn.disabled = false;
22395        cancelBtn.disabled = false;
22396      }
22397    });
22398  })();
22399  </script>
22400  <script nonce="{{ csp_nonce }}">(function(){
22401    var bundleBtn = document.getElementById('download-bundle-btn');
22402    if (bundleBtn) {
22403      bundleBtn.addEventListener('click', function() {
22404        bundleBtn.disabled = true;
22405        var orig = bundleBtn.textContent;
22406        bundleBtn.textContent = 'Preparing…';
22407        fetch('/api/runs/{{ run_id }}/bundle')
22408          .then(function(r) {
22409            if (!r.ok) throw new Error('HTTP ' + r.status);
22410            return r.blob();
22411          })
22412          .then(function(blob) {
22413            var url = URL.createObjectURL(blob);
22414            var a = document.createElement('a');
22415            a.href = url;
22416            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
22417            document.body.appendChild(a);
22418            a.click();
22419            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
22420            bundleBtn.disabled = false;
22421            bundleBtn.textContent = orig;
22422          })
22423          .catch(function(e) {
22424            bundleBtn.disabled = false;
22425            bundleBtn.textContent = orig;
22426            alert('Bundle download failed: ' + String(e));
22427          });
22428      });
22429    }
22430  })();</script>
22431  <script nonce="{{ csp_nonce }}">(function(){
22432    var dot=document.getElementById('status-dot');
22433    var pingEl=document.getElementById('server-ping-ms');
22434    var tipEl=document.getElementById('server-tip-ping');
22435    var fm=document.getElementById('footer-mode');
22436    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)';}}
22437    function doPing(){
22438      var t0=performance.now();
22439      fetch('/healthz',{cache:'no-store'})
22440        .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);})
22441        .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)';}});
22442    }
22443    doPing();
22444    setInterval(doPing,5000);
22445    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');}
22446  })();</script>
22447  {% if let Some(banner) = report_header_footer %}
22448  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
22449  {% endif %}
22450</body>
22451</html>
22452"##,
22453    ext = "html"
22454)]
22455// Template structs need many bool fields to pass Askama rendering flags.
22456#[allow(clippy::struct_excessive_bools)]
22457struct ResultTemplate {
22458    version: &'static str,
22459    report_title: String,
22460    project_path: String,
22461    output_dir: String,
22462    run_id: String,
22463    files_analyzed: u64,
22464    files_skipped: u64,
22465    physical_lines: u64,
22466    code_lines: u64,
22467    comment_lines: u64,
22468    blank_lines: u64,
22469    mixed_lines: u64,
22470    functions: u64,
22471    classes: u64,
22472    variables: u64,
22473    imports: u64,
22474    html_url: Option<String>,
22475    pdf_url: Option<String>,
22476    json_url: Option<String>,
22477    html_download_url: Option<String>,
22478    pdf_download_url: Option<String>,
22479    json_download_url: Option<String>,
22480    html_path: Option<String>,
22481    json_path: Option<String>,
22482    prev_run_id: Option<String>,
22483    prev_run_timestamp: Option<String>,
22484    prev_run_code_lines: Option<u64>,
22485    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
22486    prev_fa_str: String,
22487    prev_fs_str: String,
22488    prev_pl_str: String,
22489    prev_cl_str: String,
22490    prev_cml_str: String,
22491    prev_bl_str: String,
22492    // Signed change column for main metrics
22493    delta_fa_str: String,
22494    delta_fa_class: String,
22495    delta_fs_str: String,
22496    delta_fs_class: String,
22497    delta_pl_str: String,
22498    delta_pl_class: String,
22499    delta_cl_str: String,
22500    delta_cl_class: String,
22501    delta_cml_str: String,
22502    delta_cml_class: String,
22503    delta_bl_str: String,
22504    delta_bl_class: String,
22505    // delta vs previous scan
22506    delta_lines_added: Option<i64>,
22507    delta_lines_removed: Option<i64>,
22508    delta_lines_net_str: String,
22509    delta_lines_net_class: String,
22510    delta_files_added: Option<usize>,
22511    delta_files_removed: Option<usize>,
22512    delta_files_modified: Option<usize>,
22513    delta_files_unchanged: Option<usize>,
22514    delta_unmodified_lines: Option<u64>,
22515    // git context
22516    git_branch: Option<String>,
22517    git_branch_url: Option<String>,
22518    git_commit: Option<String>,
22519    git_commit_long: Option<String>,
22520    git_author: Option<String>,
22521    git_commit_url: Option<String>,
22522    // scan metadata for hero section
22523    scan_performed_by: String,
22524    scan_time_display: String,
22525    os_display: String,
22526    test_count: u64,
22527    // history
22528    prev_scan_count: usize,
22529    current_scan_number: usize,
22530    // submodule breakdown (empty when not requested)
22531    submodule_rows: Vec<SubmoduleRow>,
22532    scan_config_url: String,
22533    lang_chart_json: String,
22534    // Askama reads these via proc-macro expansion; clippy can't trace through it.
22535    #[allow(dead_code)]
22536    scatter_chart_json: String,
22537    #[allow(dead_code)]
22538    semantic_chart_json: String,
22539    #[allow(dead_code)]
22540    submodule_chart_json: String,
22541    #[allow(dead_code)]
22542    has_submodule_data: bool,
22543    #[allow(dead_code)]
22544    has_semantic_data: bool,
22545    pdf_generating: bool,
22546    csp_nonce: String,
22547    /// Whether Confluence integration is configured — shows Post button when true.
22548    confluence_configured: bool,
22549    server_mode: bool,
22550    /// Header/footer identification banner, mirrored from the HTML/PDF report.
22551    report_header_footer: Option<String>,
22552    run_id_short: String,
22553    /// True when rendering a static offline file (index.html); hides server-only actions.
22554    #[allow(dead_code)]
22555    is_offline: bool,
22556    /// Total cyclomatic complexity score across all analyzed files.
22557    cyclomatic_complexity: u64,
22558    /// Logical SLOC (statement count) when available; None for unsupported languages.
22559    lsloc: Option<u64>,
22560    /// Unique Lines of Code across all analyzed files.
22561    uloc: u64,
22562    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
22563    dryness_pct_str: String,
22564    /// Number of duplicate file groups detected.
22565    duplicate_group_count: usize,
22566    /// Whether a COCOMO estimate is available to display.
22567    has_cocomo: bool,
22568    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
22569    cocomo_effort_str: String,
22570    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
22571    cocomo_duration_str: String,
22572    /// Pre-formatted average team size, e.g. "2.32".
22573    cocomo_staff_str: String,
22574    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
22575    cocomo_ksloc_str: String,
22576    /// COCOMO mode label shown in the card (e.g. "Organic").
22577    cocomo_mode_label: String,
22578    /// Tooltip text explaining the selected COCOMO mode.
22579    cocomo_mode_tooltip: String,
22580    /// Per-file complexity alert threshold. 0 = off (no highlighting).
22581    complexity_alert: u32,
22582}
22583
22584#[derive(Template)]
22585#[template(
22586    source = r##"
22587<!doctype html>
22588<html lang="en">
22589<head>
22590  <meta charset="utf-8">
22591  <meta name="viewport" content="width=device-width, initial-scale=1">
22592  <title>OxideSLOC | Analyzing…</title>
22593  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22594  <style nonce="{{ csp_nonce }}">
22595    :root {
22596      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22597      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22598      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22599      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22600    }
22601    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22602    *{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;}
22603    .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);}
22604    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22605    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
22606    .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));}
22607    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22608    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22609    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22610    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22611    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22612    @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; } }
22613    .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;}
22614    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22615    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22616    .page-body{padding:32px 24px 36px;}
22617    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
22618    .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;}
22619    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
22620    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
22621    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
22622    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
22623    .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;}
22624    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
22625    .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;}
22626    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
22627    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
22628    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
22629    .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;}
22630    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
22631    .hidden{display:none!important;}
22632    .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;}
22633    .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;}
22634    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
22635    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
22636    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
22637    .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);}
22638    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
22639    .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;}
22640    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
22641    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22642    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22643    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
22644    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22645    .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;}
22646    @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));}}
22647    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22648    .site-footer a{color:var(--muted);}
22649    .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;}
22650    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
22651    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
22652    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
22653  </style>
22654</head>
22655<body>
22656  <div class="background-watermarks" aria-hidden="true">
22657    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22658    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22659    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22660    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22661    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22662    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22663  </div>
22664  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22665  <nav class="top-nav">
22666    <div class="top-nav-inner">
22667      <a href="/" class="brand">
22668        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
22669        <div class="brand-copy">
22670          <h1 class="brand-title">OxideSLOC</h1>
22671          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
22672        </div>
22673      </a>
22674      <div class="nav-right">
22675        <a class="nav-pill" href="/">Home</a>
22676        <div class="nav-dropdown">
22677          <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>
22678          <div class="nav-dropdown-menu">
22679            <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>
22680          </div>
22681        </div>
22682        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22683        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22684        <div class="nav-dropdown">
22685          <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>
22686          <div class="nav-dropdown-menu">
22687            <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>
22688          </div>
22689        </div>
22690        <div class="server-status-wrap" id="server-status-wrap">
22691          <div class="nav-pill server-online-pill" id="server-status-pill">
22692            <span class="status-dot" id="status-dot"></span>
22693            <span id="server-status-label">Server</span>
22694            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22695          </div>
22696          <div class="server-status-tip">
22697            OxideSLOC is running — accessible on your network.
22698            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22699          </div>
22700        </div>
22701        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22702          <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>
22703        </button>
22704        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22705          <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>
22706          <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>
22707        </button>
22708      </div>
22709    </div>
22710  </nav>
22711  <div class="page-body">
22712    <div class="wait-panel">
22713      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
22714      <h2 class="wait-title">Analyzing your project…</h2>
22715      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
22716      <div class="path-block">{{ project_path }}</div>
22717      <div class="metrics-row">
22718        <div class="metric-card">
22719          <div class="metric-label">Elapsed</div>
22720          <div class="metric-value" id="elapsed">0s</div>
22721        </div>
22722        <div class="metric-card">
22723          <div class="metric-label">Phase</div>
22724          <div class="metric-value" id="phase">Starting</div>
22725        </div>
22726        <div class="metric-card hidden" id="files-card">
22727          <div class="metric-label">Files</div>
22728          <div class="metric-value" id="files-progress">0</div>
22729        </div>
22730      </div>
22731      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
22732      <div class="warn-slow hidden" id="warn-slow">
22733        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.
22734      </div>
22735      <div class="err-panel hidden" id="err-panel">
22736        <strong>Analysis failed</strong>
22737        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
22738      </div>
22739      <div class="actions hidden" id="actions">
22740        <a href="/scan" class="btn-primary">Try Again</a>
22741        <a href="/view-reports" class="btn-outline">View Reports</a>
22742      </div>
22743    </div>
22744  </div>
22745  <script nonce="{{ csp_nonce }}">
22746    (function() {
22747      var WAIT_ID = {{ wait_id_json|safe }};
22748      var startTime = Date.now();
22749      var pollInterval = 1500;
22750      var retries = 0;
22751      var maxRetries = 5;
22752      var warnShown = false;
22753
22754      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();}
22755
22756      function elapsed() {
22757        return Math.floor((Date.now() - startTime) / 1000);
22758      }
22759
22760      function updateElapsed() {
22761        var s = elapsed();
22762        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
22763      }
22764
22765      function setPhase(txt) {
22766        document.getElementById('phase').textContent = txt;
22767      }
22768
22769      var elapsedTimer = setInterval(updateElapsed, 1000);
22770
22771      function poll() {
22772        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
22773          .then(function(r) {
22774            if (!r.ok) throw new Error('HTTP ' + r.status);
22775            return r.json();
22776          })
22777          .then(function(data) {
22778            retries = 0;
22779            if (data.state === 'complete') {
22780              clearInterval(elapsedTimer);
22781              setPhase('Done');
22782              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
22783            } else if (data.state === 'failed') {
22784              clearInterval(elapsedTimer);
22785              setPhase('Failed');
22786              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
22787              document.getElementById('err-panel').classList.remove('hidden');
22788              document.getElementById('actions').classList.remove('hidden');
22789            } else {
22790              // still running
22791              var s = elapsed();
22792              if (s > 90 && !warnShown) {
22793                warnShown = true;
22794                document.getElementById('warn-slow').classList.remove('hidden');
22795              }
22796              setPhase(data.phase || 'Running');
22797              var fd = data.files_done || 0, ft = data.files_total || 0;
22798              if (ft > 0) {
22799                var card = document.getElementById('files-card');
22800                if (card) card.classList.remove('hidden');
22801                var fp = document.getElementById('files-progress');
22802                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
22803              }
22804              setTimeout(poll, pollInterval);
22805            }
22806          })
22807          .catch(function(err) {
22808            retries++;
22809            if (retries >= maxRetries) {
22810              clearInterval(elapsedTimer);
22811              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
22812              document.getElementById('err-panel').classList.remove('hidden');
22813              document.getElementById('actions').classList.remove('hidden');
22814            } else {
22815              // exponential back-off capped at 8s
22816              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
22817            }
22818          });
22819      }
22820
22821      setTimeout(poll, pollInterval);
22822
22823      // If the browser restores this page from bfcache (Back after viewing results),
22824      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
22825      window.addEventListener("pageshow", function(e) {
22826        if (e.persisted) { setTimeout(poll, 200); }
22827      });
22828    })();
22829  </script>
22830  <footer class="site-footer">
22831    local code analysis - metrics, history and reports
22832    &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>
22833    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22834    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22835    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22836    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22837  </footer>
22838  <script nonce="{{ csp_nonce }}">
22839    (function(){
22840      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
22841      if(s==="dark")b.classList.add("dark-theme");
22842      var tt=document.getElementById("theme-toggle");
22843      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
22844    })();
22845    (function spawnCodeParticles(){
22846      var c=document.getElementById('code-particles');if(!c)return;
22847      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'];
22848      for(var i=0;i<32;i++){(function(idx){
22849        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
22850        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
22851        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
22852        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
22853        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
22854        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
22855        c.appendChild(el);
22856      })(i);}
22857    })();
22858    (function randomizeWatermarks(){
22859      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22860      var placed=[];
22861      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;}
22862      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];}
22863      var half=Math.floor(wms.length/2);
22864      wms.forEach(function(img,i){
22865        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
22866        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
22867        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
22868        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
22869        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
22870        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
22871      });
22872    })();
22873  </script>
22874  <script nonce="{{ csp_nonce }}">
22875  (function(){
22876    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'}];
22877    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);});}
22878    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22879    function init(){
22880      var btn=document.getElementById('settings-btn');if(!btn)return;
22881      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22882      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>';
22883      document.body.appendChild(m);
22884      var g=document.getElementById('scheme-grid');
22885      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);});
22886      var cl=document.getElementById('settings-close');
22887      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);
22888      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');});
22889      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22890      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22891    }
22892    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22893  }());
22894  </script>
22895  <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]';
22896  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;}
22897  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>
22898</body>
22899</html>
22900"##,
22901    ext = "html"
22902)]
22903struct ScanWaitTemplate {
22904    version: &'static str,
22905    wait_id_json: String,
22906    project_path: String,
22907    csp_nonce: String,
22908}
22909
22910#[derive(Template)]
22911#[template(
22912    source = r##"
22913<!doctype html>
22914<html lang="en">
22915<head>
22916  <meta charset="utf-8">
22917  <meta name="viewport" content="width=device-width, initial-scale=1">
22918  <title>OxideSLOC | Error</title>
22919  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22920  <style nonce="{{ csp_nonce }}">
22921    :root {
22922      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22923      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22924      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22925      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22926    }
22927    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22928    *{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;}
22929    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22930    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22931    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
22932    .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);}
22933    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22934    .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));}
22935    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22936    .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;}
22937    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22938    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22939    @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; } }
22940    .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;}
22941    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22942    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22943    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22944    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22945    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22946    .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;}
22947    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22948    .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);}
22949    .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;}
22950    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22951    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22952    .settings-modal-body{padding:14px 16px 16px;}
22953    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22954    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22955    .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;}
22956    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22957    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22958    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22959    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22960    .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;}
22961    .tz-select:focus{border-color:var(--oxide);}
22962    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
22963    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22964    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
22965    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
22966    .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;}
22967    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
22968    .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);}
22969    .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;}
22970    .btn-secondary:hover{background:var(--line);}
22971    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
22972    .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;}
22973    .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;}
22974    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
22975    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
22976    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
22977    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
22978    .bug-report-panel.open{display:flex;}
22979    .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;}
22980    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
22981    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
22982    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
22983    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
22984    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
22985    .br-network-badge.online .br-net-dot{background:#2a6846;}
22986    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
22987    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
22988    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
22989    .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;}
22990    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
22991    .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;}
22992    .btn-sm:hover{background:var(--line);}
22993    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
22994    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
22995    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
22996    .bug-report-hint a:hover{text-decoration:underline;}
22997    .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;}
22998    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
22999    .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;}
23000    .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;}
23001    .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;}
23002    @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));}}
23003    .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;}
23004  </style>
23005</head>
23006<body>
23007  <div class="background-watermarks" aria-hidden="true">
23008    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23009    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23010    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23011    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23012    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23013    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23014  </div>
23015  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23016  <div class="top-nav">
23017    <div class="top-nav-inner">
23018      <a class="brand" href="/">
23019        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23020        <div class="brand-copy">
23021          <div class="brand-title">OxideSLOC</div>
23022          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23023        </div>
23024      </a>
23025      <div class="nav-right">
23026        <a class="nav-pill" href="/">Home</a>
23027        <div class="nav-dropdown">
23028          <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>
23029          <div class="nav-dropdown-menu">
23030            <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>
23031          </div>
23032        </div>
23033        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23034        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23035        <div class="nav-dropdown">
23036          <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>
23037          <div class="nav-dropdown-menu">
23038            <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>
23039          </div>
23040        </div>
23041        <div class="server-status-wrap" id="server-status-wrap">
23042          <div class="nav-pill server-online-pill" id="server-status-pill">
23043            <span class="status-dot" id="status-dot"></span>
23044            <span id="server-status-label">Server</span>
23045            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23046          </div>
23047          <div class="server-status-tip">
23048            OxideSLOC is running — accessible on your network.
23049            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23050          </div>
23051        </div>
23052        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23053          <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>
23054        </button>
23055        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23056          <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>
23057          <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>
23058        </button>
23059      </div>
23060    </div>
23061  </div>
23062
23063  <div class="page">
23064    <div class="panel">
23065      <h1>Error</h1>
23066      <div class="error-box" id="error-msg-text">{{ message }}</div>
23067      <div id="br-meta" hidden
23068        data-version="{{ version }}"
23069        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
23070        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
23071      <div class="actions">
23072        <a class="btn-primary" href="/scan">Back to setup</a>
23073        {% if let Some(report_url) = last_report_url %}
23074        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
23075        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
23076        {% else %}
23077        <a class="btn-secondary" href="/view-reports">View Reports</a>
23078        {% endif %}
23079      </div>
23080      <div class="bug-report-section" id="bug-report-section">
23081        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
23082          <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>
23083          Generate Bug Report
23084          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23085        </button>
23086        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
23087          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
23088          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
23089          <div class="bug-report-btns">
23090            <button type="button" class="btn-sm" id="bug-report-copy">
23091              <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>
23092              Copy to clipboard
23093            </button>
23094            <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;">
23095              <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>
23096              Open GitHub Issue
23097            </a>
23098            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
23099              <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>
23100              Save as file
23101            </button>
23102          </div>
23103          <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>
23104          <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>
23105        </div>
23106      </div>
23107    </div>
23108  </div>
23109  <footer class="site-footer">
23110    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
23111    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23112    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23113    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23114    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23115  </footer>
23116  <script nonce="{{ csp_nonce }}">(function(){
23117    var meta=document.getElementById('br-meta');
23118    var pre=document.getElementById('bug-report-pre');
23119    var copyBtn=document.getElementById('bug-report-copy');
23120    var trigger=document.getElementById('bug-report-trigger');
23121    var panel=document.getElementById('bug-report-panel');
23122    var networkBadge=document.getElementById('br-network-badge');
23123    var networkLabel=document.getElementById('br-network-label');
23124    var ghLink=document.getElementById('bug-report-github-link');
23125    var saveBtn=document.getElementById('bug-report-save');
23126    var hintOnline=document.getElementById('br-hint-online');
23127    var hintOffline=document.getElementById('br-hint-offline');
23128    if(!meta||!pre)return;
23129    var ver=meta.getAttribute('data-version')||'';
23130    var runId=meta.getAttribute('data-run-id')||'';
23131    var code=meta.getAttribute('data-error-code')||'';
23132    var msgEl=document.getElementById('error-msg-text');
23133    var msg=msgEl?msgEl.textContent.trim():'';
23134    function getBrowser(){
23135      var ua=navigator.userAgent;
23136      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
23137      if(!m)return 'Unknown browser';
23138      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
23139      return n+' '+m[2];
23140    }
23141    var lines=['oxide-sloc Bug Report','==============================',''];
23142    lines.push('App version:  v'+ver);
23143    if(code)lines.push('HTTP status:  '+code);
23144    if(runId)lines.push('Run ID:       '+runId);
23145    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
23146    lines.push('Timestamp:    '+new Date().toISOString());
23147    lines.push('Browser:      '+getBrowser());
23148    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
23149    lines.push('');
23150    lines.push('Error message:');
23151    lines.push(msg);
23152    lines.push('');
23153    lines.push('Steps to reproduce:');
23154    lines.push('  1. ');
23155    lines.push('');
23156    lines.push('Expected behavior:');
23157    lines.push('  ');
23158    pre.textContent=lines.join('\n');
23159    function applyNetwork(online){
23160      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
23161      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
23162      if(ghLink){
23163        if(online){
23164          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
23165          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
23166        }
23167        ghLink.style.display=online?'inline-flex':'none';
23168      }
23169      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
23170      if(hintOnline)hintOnline.style.display=online?'block':'none';
23171      if(hintOffline)hintOffline.style.display=online?'none':'block';
23172    }
23173    applyNetwork(navigator.onLine);
23174    var probed=false;
23175    function probeNetwork(){
23176      if(probed)return;probed=true;
23177      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
23178      var probeIdx=0;
23179      function tryNext(){
23180        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
23181        var u=probeUrls[probeIdx++];
23182        var c2=new AbortController();
23183        var t2=setTimeout(function(){c2.abort();},4000);
23184        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
23185          .then(function(){clearTimeout(t2);applyNetwork(true);})
23186          .catch(function(){clearTimeout(t2);tryNext();});
23187      }
23188      tryNext();
23189    }
23190    if(trigger&&panel){
23191      trigger.addEventListener('click',function(){
23192        var open=panel.classList.toggle('open');
23193        trigger.classList.toggle('open',open);
23194        trigger.setAttribute('aria-expanded',open?'true':'false');
23195        if(open)probeNetwork();
23196      });
23197    }
23198    if(copyBtn){
23199      copyBtn.addEventListener('click',function(){
23200        var txt=pre.textContent;
23201        if(navigator.clipboard&&navigator.clipboard.writeText){
23202          navigator.clipboard.writeText(txt).then(function(){
23203            copyBtn.textContent='✓ Copied!';
23204            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);
23205          });
23206        }else{
23207          var ta=document.createElement('textarea');
23208          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
23209          document.body.appendChild(ta);ta.select();
23210          try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
23211          document.body.removeChild(ta);
23212        }
23213      });
23214    }
23215    if(saveBtn){
23216      saveBtn.addEventListener('click',function(){
23217        var txt=pre.textContent;
23218        var blob=new Blob([txt],{type:'text/plain'});
23219        var url=URL.createObjectURL(blob);
23220        var a=document.createElement('a');
23221        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
23222        document.body.appendChild(a);a.click();
23223        document.body.removeChild(a);URL.revokeObjectURL(url);
23224      });
23225    }
23226  })();</script>
23227  <script nonce="{{ csp_nonce }}">
23228    (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");});})();
23229    (function spawnCodeParticles() {
23230      var container = document.getElementById('code-particles');
23231      if (!container) return;
23232      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'];
23233      for (var i = 0; i < 38; i++) {
23234        (function(idx) {
23235          var el = document.createElement('span');
23236          el.className = 'code-particle';
23237          el.textContent = snippets[idx % snippets.length];
23238          var left = Math.random() * 94 + 2;
23239          var top = Math.random() * 88 + 6;
23240          var dur = (Math.random() * 10 + 9).toFixed(1);
23241          var delay = (Math.random() * 18).toFixed(1);
23242          var rot = (Math.random() * 26 - 13).toFixed(1);
23243          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23244          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';
23245          container.appendChild(el);
23246        })(i);
23247      }
23248    })();
23249    (function randomizeWatermarks() {
23250      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23251      var placed = [];
23252      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; }
23253      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]; }
23254      var half = Math.floor(wms.length/2);
23255      wms.forEach(function(img, i) {
23256        var pos = pick(i < half);
23257        var w = Math.floor(Math.random()*60+80);
23258        var rot = (Math.random()*40-20).toFixed(1);
23259        var op = (Math.random()*0.08+0.05).toFixed(2);
23260        var animDur = (Math.random()*6+5).toFixed(1);
23261        var animDelay = (Math.random()*10).toFixed(1);
23262        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';
23263      });
23264    })();
23265  </script>
23266  <script nonce="{{ csp_nonce }}">
23267  (function(){
23268    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'}];
23269    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);});}
23270    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23271    function init(){
23272      var btn=document.getElementById('settings-btn');if(!btn)return;
23273      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23274      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>';
23275      document.body.appendChild(m);
23276      var g=document.getElementById('scheme-grid');
23277      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);});
23278      var cl=document.getElementById('settings-close');
23279      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);
23280      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');});
23281      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23282      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23283    }
23284    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23285  }());
23286  </script>
23287  <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]';
23288  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;}
23289  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>
23290</body>
23291</html>
23292"##,
23293    ext = "html"
23294)]
23295struct ErrorTemplate {
23296    message: String,
23297    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
23298    last_report_url: Option<String>,
23299    /// Label for the secondary action button; defaults to "View last report" when None.
23300    last_report_label: Option<String>,
23301    /// Run ID to surface in the bug report; `None` when not applicable.
23302    run_id: Option<String>,
23303    /// HTTP status code to surface in the bug report; `None` when unknown.
23304    error_code: Option<u16>,
23305    csp_nonce: String,
23306    version: &'static str,
23307}
23308
23309// ── LocateFileTemplate ────────────────────────────────────────────────────────
23310
23311#[derive(Template)]
23312#[template(
23313    source = r##"
23314<!doctype html>
23315<html lang="en">
23316<head>
23317  <meta charset="utf-8">
23318  <meta name="viewport" content="width=device-width, initial-scale=1">
23319  <title>OxideSLOC | Locate Report</title>
23320  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23321  <style nonce="{{ csp_nonce }}">
23322    :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);}
23323    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
23324    *{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;}
23325    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23326    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23327    .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);}
23328    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23329    .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));}
23330    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23331    .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;}
23332    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23333    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23334    @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;}}
23335    .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;}
23336    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23337    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23338    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23339    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23340    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
23341    .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;}
23342    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23343    .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);}
23344    .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;}
23345    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23346    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23347    .settings-modal-body{padding:14px 16px 16px;}
23348    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23349    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23350    .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;}
23351    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23352    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23353    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23354    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23355    .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;}
23356    .tz-select:focus{border-color:var(--oxide);}
23357    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23358    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23359    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23360    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
23361    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
23362    .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;}
23363    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
23364    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23365    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23366    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23367    .locate-row{display:flex;gap:8px;align-items:stretch;}
23368    .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;}
23369    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23370    body.dark-theme .locate-input{background:var(--surface-2);}
23371    .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;}
23372    .warning-banner.show{display:flex;}
23373    .warning-banner svg{flex:0 0 auto;}
23374    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
23375    .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;}
23376    .error-inline.show{display:flex;}
23377    .error-inline svg{flex:0 0 auto;margin-top:2px;}
23378    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
23379    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
23380    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
23381    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
23382    .err-kv-p{margin:0 0 4px;}
23383    .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;}
23384    .success-inline.show{display:flex;}
23385    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23386    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
23387    .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;}
23388    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
23389    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
23390    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
23391    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
23392    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
23393    .fh-row:last-child{border-bottom:none;}
23394    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
23395    .fh-dir{font-weight:800;color:var(--text);}
23396    .fh-hl{color:var(--oxide);font-weight:700;}
23397    .fh-muted{color:var(--muted);}
23398    .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;}
23399    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
23400    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
23401    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
23402    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
23403    .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;}
23404    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
23405    .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;}
23406    .btn-secondary:hover{background:var(--line);}
23407    .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;}
23408    .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;}
23409    .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;}
23410    @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));}}
23411    .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;}
23412    .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;}
23413    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23414  </style>
23415</head>
23416<body>
23417  <div class="background-watermarks" aria-hidden="true">
23418    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23419    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23420    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23421    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23422    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23423    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23424  </div>
23425  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23426  <div class="top-nav">
23427    <div class="top-nav-inner">
23428      <a class="brand" href="/">
23429        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23430        <div class="brand-copy">
23431          <div class="brand-title">OxideSLOC</div>
23432          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23433        </div>
23434      </a>
23435      <div class="nav-right">
23436        <a class="nav-pill" href="/">Home</a>
23437        <div class="nav-dropdown">
23438          <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>
23439          <div class="nav-dropdown-menu">
23440            <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>
23441          </div>
23442        </div>
23443        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23444        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23445        <div class="nav-dropdown">
23446          <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>
23447          <div class="nav-dropdown-menu">
23448            <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>
23449          </div>
23450        </div>
23451        <div class="server-status-wrap" id="server-status-wrap">
23452          <div class="nav-pill server-online-pill" id="server-status-pill">
23453            <span class="status-dot" id="status-dot"></span>
23454            <span id="server-status-label">Server</span>
23455            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23456          </div>
23457          <div class="server-status-tip">
23458            OxideSLOC is running &mdash; accessible on your network.
23459            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23460          </div>
23461        </div>
23462        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23463          <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>
23464        </button>
23465        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23466          <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>
23467          <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>
23468        </button>
23469      </div>
23470    </div>
23471  </div>
23472
23473  <div class="page">
23474    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
23475    <div class="panel">
23476      <h1>Report File Not Found</h1>
23477      <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>
23478      <div class="field-label">Missing file</div>
23479      <div class="filename-chip">
23480        <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>
23481        {{ expected_filename }}
23482      </div>
23483      <div class="locate-section">
23484        <h2>Locate Scan Output Folder</h2>
23485        <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>
23486        <p>OxideSLOC will find the correct files inside automatically.</p>
23487        <div class="locate-row">
23488          <input type="text" id="locate-file-input"
23489                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
23490                 class="locate-input" autocomplete="off" spellcheck="false">
23491          {% if !server_mode %}
23492          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
23493          {% endif %}
23494        </div>
23495        <div class="warning-banner" id="filename-warning">
23496          <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>
23497          <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>
23498        </div>
23499        <div class="error-inline" id="locate-error">
23500          <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>
23501          <span id="locate-error-text"></span>
23502        </div>
23503        <div class="success-inline" id="locate-success">
23504          <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>
23505          <span>Scan restored &mdash; loading report&hellip;</span>
23506        </div>
23507        <div class="btn-row">
23508          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
23509          <a class="btn-secondary" href="/view-reports">View Reports</a>
23510        </div>
23511        <div class="folder-hint-shell">
23512          <div class="folder-hint-hdr">
23513            <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>
23514            Expected Folder Structure &mdash; Select the Top-Level Folder
23515          </div>
23516          <div class="folder-hint-body">
23517            <div class="fh-row">
23518              <span class="fh-tog">&#9658;</span>
23519              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
23520              <span class="fh-badge">&larr; select this</span>
23521            </div>
23522            <div class="fh-row fh-i1">
23523              <span class="fh-tog">&#9658;</span>
23524              <span class="fh-dir">html/</span>
23525            </div>
23526            <div class="fh-row fh-i2">
23527              <span class="fh-bul">&#8226;</span>
23528              <span class="fh-hl">{{ expected_filename }}</span>
23529            </div>
23530            <div class="fh-row fh-i1">
23531              <span class="fh-tog">&#9658;</span>
23532              <span class="fh-dir">json/</span>
23533            </div>
23534            <div class="fh-row fh-i2">
23535              <span class="fh-bul">&#8226;</span>
23536              <span class="fh-muted">result_*.json</span>
23537            </div>
23538            <div class="fh-row fh-i1">
23539              <span class="fh-tog">&#9658;</span>
23540              <span class="fh-dir">pdf/</span>
23541            </div>
23542            <div class="fh-row fh-i2">
23543              <span class="fh-bul">&#8226;</span>
23544              <span class="fh-muted">report_*.pdf</span>
23545            </div>
23546            <div class="fh-row fh-i1">
23547              <span class="fh-tog">&#9658;</span>
23548              <span class="fh-dir">excel/</span>
23549            </div>
23550            <div class="fh-row fh-i2">
23551              <span class="fh-bul">&#8226;</span>
23552              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
23553            </div>
23554          </div>
23555        </div>
23556      </div>
23557    </div>
23558  </div>
23559  <footer class="site-footer">
23560    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
23561    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23562    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23563    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23564    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23565  </footer>
23566  <script nonce="{{ csp_nonce }}">(function(){
23567    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
23568    if(s==="dark")b.classList.add("dark-theme");
23569    document.getElementById("theme-toggle").addEventListener("click",function(){
23570      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
23571    });
23572  })();</script>
23573  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
23574    var c=document.getElementById('code-particles');if(!c)return;
23575    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'];
23576    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);}
23577  })();
23578  (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>
23579  <script nonce="{{ csp_nonce }}">(function(){
23580    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'}];
23581    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);});}
23582    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23583    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');});}
23584    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23585  }());</script>
23586  <script nonce="{{ csp_nonce }}">(function(){
23587    var meta=document.getElementById('locate-meta');
23588    var inp=document.getElementById('locate-file-input');
23589    var browseBtn=document.getElementById('browse-locate-btn');
23590    var submitBtn=document.getElementById('locate-submit-btn');
23591    var warning=document.getElementById('filename-warning');
23592    var errBox=document.getElementById('locate-error');
23593    var errText=document.getElementById('locate-error-text');
23594    var okBox=document.getElementById('locate-success');
23595    var expected=meta?meta.getAttribute('data-expected'):'';
23596    var runId=meta?meta.getAttribute('data-run-id'):'';
23597    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
23598    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
23599    function showErr(msg){
23600      if(errText){
23601        errText.innerHTML='';
23602        var lines=msg.split('\n');
23603        var hasPairs=lines.some(function(l){return / : /.test(l);});
23604        if(!hasPairs){errText.textContent=msg;}
23605        else{
23606          var frag=document.createDocumentFragment();var tbl=null;
23607          lines.forEach(function(line){
23608            var m=line.match(/^(.*?) : (.*)$/);
23609            if(m){
23610              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
23611              var tr=document.createElement('tr');
23612              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
23613              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
23614              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
23615            } else {
23616              tbl=null;
23617              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
23618            }
23619          });
23620          errText.appendChild(frag);
23621        }
23622      }
23623      if(errBox)errBox.classList.add('show');
23624      if(okBox)okBox.classList.remove('show');
23625    }
23626    function clearErr(){
23627      if(errBox)errBox.classList.remove('show');
23628      if(okBox)okBox.classList.remove('show');
23629    }
23630    function validate(){
23631      var val=inp?inp.value.trim():'';
23632      clearErr();
23633      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
23634      if(submitBtn)submitBtn.disabled=false;
23635      if(warning){
23636        var name=basename(val);
23637        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
23638        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
23639        else warning.classList.remove('show');
23640      }
23641    }
23642    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
23643    if(browseBtn){
23644      browseBtn.addEventListener('click',function(){
23645        browseBtn.disabled=true;browseBtn.textContent='...';
23646        fetch('/pick-directory')
23647          .then(function(r){return r.ok?r.json():{cancelled:true};})
23648          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
23649          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23650      });
23651    }
23652    if(submitBtn){
23653      submitBtn.addEventListener('click',function(){
23654        var folder=inp?inp.value.trim():'';
23655        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
23656        clearErr();
23657        submitBtn.disabled=true;submitBtn.textContent='Restoring…';
23658        var body=new URLSearchParams();
23659        body.set('file_path',folder);
23660        body.set('redirect_url',redirectUrl);
23661        body.set('expected_run_id',runId);
23662        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23663          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
23664          .then(function(d){
23665            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23666            if(d&&d.ok){
23667              if(okBox)okBox.classList.add('show');
23668              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
23669            } else {
23670              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
23671            }
23672          })
23673          .catch(function(e){
23674            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23675            showErr('Network error: '+String(e));
23676          });
23677      });
23678    }
23679  })();</script>
23680  <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>
23681</body>
23682</html>
23683"##,
23684    ext = "html"
23685)]
23686struct LocateFileTemplate {
23687    run_id: String,
23688    artifact_type: String,
23689    expected_filename: String,
23690    server_mode: bool,
23691    csp_nonce: String,
23692    version: &'static str,
23693}
23694
23695// ── RelocateScanTemplate ──────────────────────────────────────────────────────
23696
23697#[derive(Template)]
23698#[template(
23699    source = r##"
23700<!doctype html>
23701<html lang="en">
23702<head>
23703  <meta charset="utf-8">
23704  <meta name="viewport" content="width=device-width, initial-scale=1">
23705  <title>OxideSLOC | Locate Scan Files</title>
23706  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23707  <style nonce="{{ csp_nonce }}">
23708    :root {
23709      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23710      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23711      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
23712      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23713    }
23714    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
23715    *{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;}
23716    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23717    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23718    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
23719    .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);}
23720    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23721    .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));}
23722    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23723    .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;}
23724    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23725    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23726    @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;}}
23727    .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;}
23728    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23729    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23730    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23731    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23732    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23733    .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;}
23734    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23735    .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);}
23736    .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;}
23737    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23738    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23739    .settings-modal-body{padding:14px 16px 16px;}
23740    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23741    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23742    .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;}
23743    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23744    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23745    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23746    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23747    .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;}
23748    .tz-select:focus{border-color:var(--oxide);}
23749    .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23750    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23751    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23752    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
23753    .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;}
23754    .error-box.hidden{display:none;}
23755    .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;}
23756    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23757    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23758    .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;}
23759    .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;}
23760    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
23761    .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;}
23762    .btn-secondary:hover{background:var(--line);}
23763    .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;}
23764    .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;}
23765    .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;}
23766    @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));}}
23767    .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;}
23768    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23769    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23770    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23771    .relocate-row{display:flex;gap:8px;align-items:stretch;}
23772    .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;}
23773    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23774    body.dark-theme .relocate-input{background:var(--surface-2);}
23775  </style>
23776</head>
23777<body>
23778  <div class="background-watermarks" aria-hidden="true">
23779    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23780    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23781    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23782    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23783    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23784    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23785  </div>
23786  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23787  <div class="top-nav">
23788    <div class="top-nav-inner">
23789      <a class="brand" href="/">
23790        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23791        <div class="brand-copy">
23792          <div class="brand-title">OxideSLOC</div>
23793          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23794        </div>
23795      </a>
23796      <div class="nav-right">
23797        <a class="nav-pill" href="/">Home</a>
23798        <div class="nav-dropdown">
23799          <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>
23800          <div class="nav-dropdown-menu">
23801            <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>
23802          </div>
23803        </div>
23804        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
23805        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23806        <div class="nav-dropdown">
23807          <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>
23808          <div class="nav-dropdown-menu">
23809            <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>
23810          </div>
23811        </div>
23812        <div class="server-status-wrap" id="server-status-wrap">
23813          <div class="nav-pill server-online-pill" id="server-status-pill">
23814            <span class="status-dot" id="status-dot"></span>
23815            <span id="server-status-label">Server</span>
23816            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23817          </div>
23818          <div class="server-status-tip">
23819            OxideSLOC is running — accessible on your network.
23820            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23821          </div>
23822        </div>
23823        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23824          <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>
23825        </button>
23826        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23827          <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>
23828          <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>
23829        </button>
23830      </div>
23831    </div>
23832  </div>
23833
23834  <div class="page">
23835    <div class="panel">
23836      <h1>Scan Files Moved</h1>
23837      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
23838      <div class="error-box" id="relocate-error-box">{{ message }}</div>
23839      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
23840      <div class="relocate-section">
23841        <h2>Locate Scan Output</h2>
23842        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
23843        <div class="relocate-row">
23844          <input type="text" id="relocate-folder" name="folder_path"
23845                 value="{{ folder_hint }}"
23846                 placeholder="Path to folder containing scan output..."
23847                 class="relocate-input" autocomplete="off" spellcheck="false">
23848          {% if !server_mode %}
23849          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
23850          {% endif %}
23851        </div>
23852        <div style="margin-top:12px;">
23853          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
23854        </div>
23855      </div>
23856      <div class="actions">
23857        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
23858        <a class="btn-secondary" href="/view-reports">View Reports</a>
23859      </div>
23860    </div>
23861  </div>
23862  <footer class="site-footer">
23863    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
23864    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23865    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23866    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23867    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23868  </footer>
23869  <script nonce="{{ csp_nonce }}">
23870    (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");});})();
23871    (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);}})();
23872    (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;});})();
23873  </script>
23874  <script nonce="{{ csp_nonce }}">
23875  (function(){
23876    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'}];
23877    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);});}
23878    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23879    function init(){
23880      var btn=document.getElementById('settings-btn');if(!btn)return;
23881      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23882      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>';
23883      document.body.appendChild(m);
23884      var g=document.getElementById('scheme-grid');
23885      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);});
23886      var cl=document.getElementById('settings-close');
23887      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);
23888      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');});
23889      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23890      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23891    }
23892    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23893  }());
23894  (function(){
23895    var browseBtn=document.getElementById('browse-relocate-btn');
23896    if(browseBtn){
23897      browseBtn.addEventListener('click',function(){
23898        browseBtn.disabled=true;browseBtn.textContent='...';
23899        var inp=document.getElementById('relocate-folder');
23900        var hint=inp?inp.value:'';
23901        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
23902          .then(function(r){return r.ok?r.json():{cancelled:true};})
23903          .then(function(d){
23904            browseBtn.disabled=false;browseBtn.textContent='Browse…';
23905            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
23906          })
23907          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23908      });
23909    }
23910    var restoreBtn=document.getElementById('restore-btn');
23911    var errBox=document.getElementById('relocate-error-box');
23912    var okBox=document.getElementById('relocate-success-box');
23913    if(restoreBtn){
23914      restoreBtn.addEventListener('click',function(){
23915        var inp=document.getElementById('relocate-folder');
23916        var folder=inp?inp.value.trim():'';
23917        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
23918        restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
23919        var body=new URLSearchParams();
23920        body.set('run_id','{{ run_id }}');
23921        body.set('redirect_url','{{ redirect_url }}');
23922        body.set('folder_path',folder);
23923        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23924          .then(function(r){return r.json();})
23925          .then(function(d){
23926            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23927            if(d&&d.ok){
23928              if(errBox)errBox.classList.add('hidden');
23929              if(okBox){okBox.style.display='block';}
23930              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
23931            } else {
23932              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
23933            }
23934          })
23935          .catch(function(e){
23936            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23937            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
23938          });
23939      });
23940    }
23941  }());
23942  </script>
23943  <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]';
23944  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;}
23945  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>
23946</body>
23947</html>
23948"##,
23949    ext = "html"
23950)]
23951struct RelocateScanTemplate {
23952    message: String,
23953    run_id: String,
23954    folder_hint: String,
23955    redirect_url: String,
23956    server_mode: bool,
23957    csp_nonce: String,
23958    version: &'static str,
23959}
23960
23961// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
23962
23963#[derive(Template)]
23964#[template(
23965    source = r##"
23966<!doctype html>
23967<html lang="en">
23968<head>
23969  <meta charset="utf-8">
23970  <meta name="viewport" content="width=device-width, initial-scale=1">
23971  <title>OxideSLOC | View Reports</title>
23972  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23973  <style nonce="{{ csp_nonce }}">
23974    :root {
23975      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
23976      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23977      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23978      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23979      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
23980    }
23981    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; }
23982    *{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;}
23983    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23984    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23985    .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);}
23986    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23987    .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));}
23988    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23989    .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;}
23990    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23991    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23992    @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; } }
23993    .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;}
23994    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23995    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23996    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23997    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23998    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23999    .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;}
24000    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24001    .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);}
24002    .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;}
24003    .settings-close:hover{color:var(--text);background:var(--surface-2);}
24004    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24005    .settings-modal-body{padding:14px 16px 16px;}
24006    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24007    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24008    .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;}
24009    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24010    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24011    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24012    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24013    .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;}
24014    .tz-select:focus{border-color:var(--oxide);}
24015    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24016    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24017    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24018    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24019    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24020    .panel-meta{font-size:13px;color:var(--muted);}
24021    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24022    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24023    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24024    .per-page-label{font-size:13px;color:var(--muted);}
24025    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;}
24026    .filter-input{min-width:180px;cursor:text;}
24027    .table-wrap{width:100%;overflow-x:auto;}
24028    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
24029    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;}
24030    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24031    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24032    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24033    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24034    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24035    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24036    tr:last-child td{border-bottom:none;}
24037    tr:hover td{background:var(--surface-2);}
24038    .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);}
24039    .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);}
24040    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24041    .metric-num{font-weight:700;color:var(--text);}
24042    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24043    .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;}
24044    .btn:hover{background:var(--line);}
24045    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24046    .btn.primary:hover{opacity:.9;}
24047    .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;}
24048    .btn-back:hover{background:var(--line);}
24049    .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;}
24050    .export-btn:hover{background:var(--line);}
24051    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
24052    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
24053    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
24054    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24055    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24056    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24057    .pagination-info{font-size:13px;color:var(--muted);}
24058    .pagination-btns{display:flex;gap:6px;}
24059    .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;}
24060    .pg-btn:hover:not(:disabled){background:var(--line);}
24061    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24062    .pg-btn:disabled{opacity:.35;cursor:default;}
24063    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24064    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24065    .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;}
24066    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24067    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24068    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24069    .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);}
24070    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24071    .stat-chip:hover .stat-chip-tip{opacity:1;}
24072    .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;}
24073    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24074    .site-footer a{color:var(--muted);}
24075    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24076    .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%;}
24077    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
24078    .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;}
24079    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
24080    .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;}
24081    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
24082    .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;}
24083    .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;}
24084    .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;}
24085    @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));}}
24086    .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;}
24087    .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;}
24088    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24089    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24090    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24091    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24092    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24093    .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;}
24094    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24095    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24096    .watched-chip-rm:hover{color:var(--oxide);}
24097    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24098    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24099    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24100    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24101    .rpt-btn{min-width:58px;justify-content:center;}
24102    .flex-row{display:flex;align-items:center;gap:8px;}
24103    .report-cell{overflow:visible;white-space:normal;}
24104    #history-table col:nth-child(1){width:185px;}
24105    #history-table col:nth-child(2){width:220px;}
24106    #history-table col:nth-child(3){width:100px;}
24107    #history-table col:nth-child(4){width:72px;}
24108    #history-table col:nth-child(5){width:82px;}
24109    #history-table col:nth-child(6){width:82px;}
24110    #history-table col:nth-child(7){width:65px;}
24111    #history-table col:nth-child(8){width:90px;}
24112    #history-table col:nth-child(9){width:85px;}
24113    #history-table col:nth-child(10){width:115px;}
24114    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
24115    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
24116    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
24117    .submod-details summary::-webkit-details-marker{display:none;}
24118.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
24119    .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;}
24120    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
24121    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
24122  </style>
24123</head>
24124<body>
24125  <div class="background-watermarks" aria-hidden="true">
24126    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24127    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24128    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24129    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24130    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24131    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24132  </div>
24133  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24134  <div class="top-nav">
24135    <div class="top-nav-inner">
24136      <a class="brand" href="/">
24137        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24138        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
24139      </a>
24140      <div class="nav-right">
24141        <a class="nav-pill" href="/">Home</a>
24142        <div class="nav-dropdown">
24143          <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>
24144          <div class="nav-dropdown-menu">
24145            <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>
24146          </div>
24147        </div>
24148        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24149        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24150        <div class="nav-dropdown">
24151          <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>
24152          <div class="nav-dropdown-menu">
24153            <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>
24154          </div>
24155        </div>
24156        <div class="server-status-wrap" id="server-status-wrap">
24157          <div class="nav-pill server-online-pill" id="server-status-pill">
24158            <span class="status-dot" id="status-dot"></span>
24159            <span id="server-status-label">Server</span>
24160            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24161          </div>
24162          <div class="server-status-tip">
24163            OxideSLOC is running — accessible on your network.
24164            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24165          </div>
24166        </div>
24167        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24168          <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>
24169        </button>
24170        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24171          <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>
24172          <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>
24173        </button>
24174      </div>
24175    </div>
24176  </div>
24177
24178  <div class="page">
24179    {% if let Some(err) = browse_error %}
24180    <div class="toast-error">
24181      <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>
24182      {{ err }}
24183    </div>
24184    {% endif %}
24185    {% if linked_count > 0 %}
24186    <div class="toast-success">
24187      <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>
24188      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
24189    </div>
24190    {% endif %}
24191    <div class="watched-bar">
24192      <div class="watched-bar-left">
24193        <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>
24194        <span class="watched-label">Watched Folders</span>
24195        <div class="watched-chips">
24196          {% if server_mode %}
24197          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24198          {% else %}
24199          {% for dir in watched_dirs %}
24200          <span class="watched-chip">
24201            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24202            <form method="POST" action="/watched-dirs/remove" style="display:contents">
24203              <input type="hidden" name="folder_path" value="{{ dir }}">
24204              <input type="hidden" name="redirect_to" value="/view-reports">
24205              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
24206            </form>
24207          </span>
24208          {% endfor %}
24209          {% if watched_dirs.is_empty() %}
24210          <span class="watched-none">No folders watched — click Choose to add one</span>
24211          {% endif %}
24212          {% endif %}
24213        </div>
24214      </div>
24215      {% if !server_mode %}
24216      <div class="watched-bar-right">
24217        <button type="button" class="btn" id="add-watched-btn">
24218          <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>
24219          Choose
24220        </button>
24221        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24222          <input type="hidden" name="redirect_to" value="/view-reports">
24223          <button type="submit" class="btn">&#8635; Refresh</button>
24224        </form>
24225      </div>
24226      {% endif %}
24227    </div>
24228    {% if total_scans > 0 %}
24229    <div class="summary-strip">
24230      <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>
24231      <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>
24232      <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>
24233      <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>
24234    </div>
24235    {% endif %}
24236
24237    <section class="panel">
24238      <div class="panel-header">
24239        <div>
24240          <h1>View Reports</h1>
24241          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
24242          {% 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 %}
24243        </div>
24244        <div class="flex-row">
24245          <button type="button" class="export-btn" id="export-csv-btn">
24246            <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>
24247            Export CSV
24248          </button>
24249          <button type="button" class="export-btn" id="export-xls-btn">
24250            <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>
24251            Export Excel
24252          </button>
24253        </div>
24254      </div>
24255
24256      {% if entries.is_empty() %}
24257      <div class="empty-state">
24258        <strong>No reports with viewable HTML yet</strong>
24259        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.
24260      </div>
24261      {% else %}
24262      <div class="filter-row">
24263        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
24264        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24265        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
24266      </div>
24267      <div class="table-wrap">
24268        <table id="history-table">
24269          <colgroup>
24270            <col><col><col><col><col><col><col><col><col><col>
24271          </colgroup>
24272          <thead>
24273            <tr id="history-thead">
24274              <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>
24275              <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>
24276              <th>Run ID<div class="col-resize-handle"></div></th>
24277              <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>
24278              <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>
24279              <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>
24280              <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>
24281              <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>
24282              <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>
24283              <th>Report<div class="col-resize-handle"></div></th>
24284            </tr>
24285          </thead>
24286          <tbody id="history-tbody">
24287            {% for entry in entries %}
24288            <tr class="history-row" data-run="{{ entry.run_id }}"
24289                data-timestamp="{{ entry.timestamp }}"
24290                data-project="{{ entry.project_label }}"
24291                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
24292                data-skipped="{{ entry.files_skipped }}"
24293                data-comments="{{ entry.comment_lines }}"
24294                data-blank="{{ entry.blank_lines }}"
24295                data-branch="{{ entry.git_branch }}"
24296                data-commit="{{ entry.git_commit }}"
24297                data-html-url="/runs/html/{{ entry.run_id }}">
24298              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
24299              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
24300              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
24301              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
24302              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
24303              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
24304              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
24305              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
24306              <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>
24307              <td class="report-cell">
24308                <div class="actions-cell">
24309                  {% 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 %}
24310                  {% 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 %}
24311                </div>
24312                {% if !entry.submodule_links.is_empty() %}
24313                <details class="submod-details">
24314                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
24315                  <div class="submod-link-list">
24316                    {% for sub in entry.submodule_links %}
24317                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
24318                    {% endfor %}
24319                  </div>
24320                </details>
24321                {% endif %}
24322              </td>
24323            </tr>
24324            {% endfor %}
24325          </tbody>
24326        </table>
24327      </div>
24328      <div class="pagination">
24329        <span class="pagination-info" id="pagination-info"></span>
24330        <div class="pagination-btns" id="pagination-btns"></div>
24331        <div class="flex-row">
24332          <span class="per-page-label">Show</span>
24333          <select class="per-page" id="per-page-sel">
24334            <option value="10">10 per page</option>
24335            <option value="25" selected>25 per page</option>
24336            <option value="50">50 per page</option>
24337            <option value="100">100 per page</option>
24338          </select>
24339          <span class="per-page-label" id="page-range-label"></span>
24340        </div>
24341      </div>
24342      {% endif %}
24343    </section>
24344  </div>
24345
24346  <footer class="site-footer">
24347    local code analysis - metrics, history and reports
24348    &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>
24349    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24350    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24351    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24352    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
24353  </footer>
24354
24355  <script nonce="{{ csp_nonce }}">
24356    (function () {
24357      // ── Theme ──────────────────────────────────────────────────────────────
24358      var storageKey = 'oxide-sloc-theme';
24359      var body = document.body;
24360      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
24361      var toggle = document.getElementById('theme-toggle');
24362      if (toggle) toggle.addEventListener('click', function () {
24363        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
24364        body.classList.toggle('dark-theme', next === 'dark');
24365        try { localStorage.setItem(storageKey, next); } catch(e) {}
24366      });
24367
24368      // ── State ─────────────────────────────────────────────────────────────
24369      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
24370      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
24371      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
24372
24373      // Aggregate stats from first (most recent) row
24374      if (allRows.length) {
24375        var first = allRows[0];
24376        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();}
24377        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>':'');}
24378        setChipVal('agg-code', first.dataset.code);
24379        setChipVal('agg-files', first.dataset.files);
24380        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
24381        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
24382      }
24383
24384      // ── Branch filter population ──────────────────────────────────────────
24385      (function() {
24386        var branches = {};
24387        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
24388        var sel = document.getElementById('branch-filter');
24389        if (sel) Object.keys(branches).sort().forEach(function(b) {
24390          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
24391        });
24392      })();
24393
24394      // ── Filter ────────────────────────────────────────────────────────────
24395      function getFilteredRows() {
24396        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
24397        var branch = ((document.getElementById('branch-filter') || {}).value || '');
24398        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
24399          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
24400          if (branch && (r.dataset.branch || '') !== branch) return false;
24401          return true;
24402        });
24403      }
24404
24405      // ── Pagination ────────────────────────────────────────────────────────
24406      function renderPage() {
24407        var filtered = getFilteredRows();
24408        var total = filtered.length;
24409        var totalPages = Math.max(1, Math.ceil(total / perPage));
24410        currentPage = Math.min(currentPage, totalPages);
24411        var start = (currentPage - 1) * perPage;
24412        var end = Math.min(start + perPage, total);
24413        var shown = {};
24414        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
24415        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
24416          r.style.display = shown[r.dataset.run] ? '' : 'none';
24417        });
24418        var rl = document.getElementById('page-range-label');
24419        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
24420        var info = document.getElementById('pagination-info');
24421        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
24422        var btns = document.getElementById('pagination-btns');
24423        if (!btns) return;
24424        btns.innerHTML = '';
24425        function makeBtn(lbl, pg, active, disabled) {
24426          var b = document.createElement('button');
24427          b.className = 'pg-btn' + (active ? ' active' : '');
24428          b.textContent = lbl; b.disabled = disabled;
24429          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
24430          return b;
24431        }
24432        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
24433        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
24434        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
24435        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
24436      }
24437
24438      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
24439      window.applyFilters = function() { currentPage = 1; renderPage(); };
24440
24441      // ── Sorting ───────────────────────────────────────────────────────────
24442      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
24443      function doSort(col, type, order) {
24444        var tbody = document.getElementById('history-tbody');
24445        if (!tbody) return;
24446        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24447        rows.sort(function(a, b) {
24448          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
24449          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
24450          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
24451          return va < vb ? 1 : va > vb ? -1 : 0;
24452        });
24453        rows.forEach(function(r) { tbody.appendChild(r); });
24454        currentPage = 1; renderPage();
24455      }
24456      sortHeaders.forEach(function(th) {
24457        th.addEventListener('click', function(e) {
24458          if (e.target.classList.contains('col-resize-handle')) return;
24459          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
24460          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
24461          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24462          th.classList.add('sort-' + sortOrder);
24463          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
24464          doSort(col, type, sortOrder);
24465        });
24466      });
24467
24468      // ── Column resize ─────────────────────────────────────────────────────
24469      (function() {
24470        var table = document.getElementById('history-table');
24471        if (!table) return;
24472        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
24473        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
24474        ths.forEach(function(th, i) {
24475          var handle = th.querySelector('.col-resize-handle');
24476          if (!handle || !cols[i]) return;
24477          var startX, startW;
24478          handle.addEventListener('mousedown', function(e) {
24479            e.stopPropagation(); e.preventDefault();
24480            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
24481            handle.classList.add('dragging');
24482            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
24483            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
24484            document.addEventListener('mousemove', onMove);
24485            document.addEventListener('mouseup', onUp);
24486          });
24487        });
24488      })();
24489
24490      // ── Reset view ────────────────────────────────────────────────────────
24491      window.resetView = function() {
24492        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
24493        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
24494        sortCol = null; sortOrder = 'asc';
24495        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24496        var tbody = document.getElementById('history-tbody');
24497        if (tbody) {
24498          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24499          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
24500          rows.forEach(function(r) { tbody.appendChild(r); });
24501        }
24502        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
24503        var table = document.getElementById('history-table');
24504        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
24505        currentPage = 1; renderPage();
24506      };
24507
24508      renderPage();
24509
24510      // ── Export helpers ────────────────────────────────────────────────────
24511      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
24512      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
24513      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);}
24514      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;');}
24515      function slocXlsx(fname,sheet,hdrs,rows){
24516        var enc=new TextEncoder();
24517        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;}
24518        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;}
24519        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
24520        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
24521        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24522        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;}
24523        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];}
24524        var rx='<row r="1">';
24525        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
24526        rx+='</row>';
24527        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>';});
24528        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
24529        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>';
24530        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>';
24531        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>';
24532        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>',
24533          '_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>',
24534          '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>',
24535          '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>',
24536          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
24537        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'];
24538        var zparts=[],zcds=[],zoff=0,znf=0;
24539        order.forEach(function(name){
24540          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
24541          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]);
24542          var entry=new Uint8Array(lha.length+nb.length+sz);
24543          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
24544          zparts.push(entry);
24545          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));
24546          var cde=new Uint8Array(cda.length+nb.length);
24547          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
24548          zcds.push(cde);zoff+=entry.length;znf++;
24549        });
24550        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
24551        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]);
24552        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
24553        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
24554        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
24555        zout.set(new Uint8Array(ea),zpos);
24556        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
24557      }
24558
24559      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
24560      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;}
24561      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
24562      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
24563
24564      var csvBtn = document.getElementById('export-csv-btn');
24565      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
24566      var xlsBtn = document.getElementById('export-xls-btn');
24567      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
24568
24569      // ── Remaining CSP-safe event bindings ────────────────────────────────
24570      (function wireEvents() {
24571        var el;
24572        el = document.getElementById('reset-view-btn');
24573        if (el) el.addEventListener('click', window.resetView);
24574        el = document.getElementById('project-filter');
24575        if (el) el.addEventListener('input', window.applyFilters);
24576        el = document.getElementById('branch-filter');
24577        if (el) el.addEventListener('change', window.applyFilters);
24578        el = document.getElementById('per-page-sel');
24579        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
24580        el = document.getElementById('add-watched-btn');
24581        if (el) el.addEventListener('click', function() {
24582          fetch('/pick-directory?kind=reports')
24583            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
24584            .then(function(data) {
24585              if (!data.cancelled && data.selected_path) {
24586                var form = document.createElement('form');
24587                form.method = 'POST';
24588                form.action = '/watched-dirs/add';
24589                var ri = document.createElement('input');
24590                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
24591                var fi = document.createElement('input');
24592                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
24593                form.appendChild(ri); form.appendChild(fi);
24594                document.body.appendChild(form);
24595                form.submit();
24596              }
24597            })
24598            .catch(function(e) { alert('Could not open folder picker: ' + e); });
24599        });
24600      })();
24601
24602      (function randomizeWatermarks() {
24603        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
24604        if (!wms.length) return;
24605        var placed = [];
24606        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;}
24607        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];}
24608        var half=Math.floor(wms.length/2);
24609        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;});
24610      })();
24611
24612      (function spawnCodeParticles() {
24613        var container = document.getElementById('code-particles');
24614        if (!container) return;
24615        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'];
24616        for (var i = 0; i < 38; i++) {
24617          (function(idx) {
24618            var el = document.createElement('span');
24619            el.className = 'code-particle';
24620            el.textContent = snippets[idx % snippets.length];
24621            var left = Math.random() * 94 + 2;
24622            var top = Math.random() * 88 + 6;
24623            var dur = (Math.random() * 10 + 9).toFixed(1);
24624            var delay = (Math.random() * 18).toFixed(1);
24625            var rot = (Math.random() * 26 - 13).toFixed(1);
24626            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24627            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';
24628            container.appendChild(el);
24629          })(i);
24630        }
24631      })();
24632    })();
24633  </script>
24634  <script nonce="{{ csp_nonce }}">
24635  (function(){
24636    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'}];
24637    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);});}
24638    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24639    function init(){
24640      var btn=document.getElementById('settings-btn');if(!btn)return;
24641      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24642      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>';
24643      document.body.appendChild(m);
24644      var g=document.getElementById('scheme-grid');
24645      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);});
24646      var cl=document.getElementById('settings-close');
24647      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);
24648      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');});
24649      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24650      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24651    }
24652    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24653  }());
24654  </script>
24655  <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>
24656</body>
24657</html>
24658"##,
24659    ext = "html"
24660)]
24661struct HistoryTemplate {
24662    version: &'static str,
24663    entries: Vec<HistoryEntryRow>,
24664    total_scans: usize,
24665    linked_count: usize,
24666    browse_error: Option<String>,
24667    watched_dirs: Vec<String>,
24668    csp_nonce: String,
24669    server_mode: bool,
24670}
24671
24672// ── CompareSelectTemplate ──────────────────────────────────────────────────────
24673
24674#[derive(Template)]
24675#[template(
24676    source = r##"
24677<!doctype html>
24678<html lang="en">
24679<head>
24680  <meta charset="utf-8">
24681  <meta name="viewport" content="width=device-width, initial-scale=1">
24682  <title>OxideSLOC | Compare Scans</title>
24683  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24684  <style nonce="{{ csp_nonce }}">
24685    :root {
24686      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24687      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24688      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24689      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24690      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
24691    }
24692    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24693    *{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;}
24694    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24695    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24696    .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);}
24697    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24698    .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));}
24699    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24700    .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;}
24701    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24702    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24703    @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; } }
24704    .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;}
24705    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24706    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24707    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24708    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24709    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24710    .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;}
24711    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24712    .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);}
24713    .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;}
24714    .settings-close:hover{color:var(--text);background:var(--surface-2);}
24715    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24716    .settings-modal-body{padding:14px 16px 16px;}
24717    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24718    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24719    .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;}
24720    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24721    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24722    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24723    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24724    .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;}
24725    .tz-select:focus{border-color:var(--oxide);}
24726    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24727    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24728    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24729    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24730    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24731    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
24732    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
24733    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24734    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24735    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24736    .per-page-label{font-size:13px;color:var(--muted);}
24737    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;}
24738    .filter-input{min-width:180px;cursor:text;}
24739    .table-wrap{width:100%;overflow-x:auto;}
24740    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
24741    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;}
24742    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24743    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24744    #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;}
24745    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
24746    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
24747    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
24748    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
24749    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
24750    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
24751    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
24752    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
24753    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
24754    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24755    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24756    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24757    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24758    tr:last-child td{border-bottom:none;}
24759    tr.selected td{background:var(--sel-bg);}
24760    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
24761    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
24762    tr{cursor:pointer;}
24763    tr.row-locked{opacity:.35;cursor:not-allowed;}
24764    tr.row-locked td{pointer-events:none;}
24765    .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;}
24766    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
24767    .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;}
24768    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
24769    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
24770    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
24771    .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);}
24772    .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);}
24773    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24774    .metric-num{font-weight:700;color:var(--text);}
24775    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24776    .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;}
24777    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
24778    .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;}
24779    .btn:hover{background:var(--line);}
24780    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
24781    .btn.primary:hover{opacity:.9;}
24782    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
24783    .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;}
24784    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24785    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24786    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24787    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24788    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24789    .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;}
24790    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24791    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24792    .watched-chip-rm:hover{color:var(--oxide);}
24793    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24794    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24795    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24796    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24797    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
24798    .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;}
24799    .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;}
24800    .btn-back:hover{background:var(--line);}
24801    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24802    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24803    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24804    .pagination-info{font-size:13px;color:var(--muted);}
24805    .pagination-btns{display:flex;gap:6px;}
24806    .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;}
24807    .pg-btn:hover:not(:disabled){background:var(--line);}
24808    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24809    .pg-btn:disabled{opacity:.35;cursor:default;}
24810    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
24811    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24812    .site-footer a{color:var(--muted);}
24813    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24814    .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;}
24815    .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;}
24816    .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;}
24817    @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));}}
24818    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24819    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24820    .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;}
24821    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24822    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24823    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24824    .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);}
24825    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24826    .stat-chip:hover .stat-chip-tip{opacity:1;}
24827    .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;}
24828    .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;}
24829    .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%;}
24830    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
24831    .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;}
24832    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
24833    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
24834    .hidden{display:none!important;}
24835    .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%;}
24836    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
24837    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
24838    .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;}
24839    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
24840    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
24841    .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;}
24842    .scope-option:hover{background:var(--line);}
24843    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
24844    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
24845    .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;}
24846    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
24847    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
24848    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
24849    .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;}
24850  </style>
24851</head>
24852<body>
24853  <div class="background-watermarks" aria-hidden="true">
24854    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24855    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24856    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24857    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24858    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24859    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24860  </div>
24861  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24862  <div class="top-nav">
24863    <div class="top-nav-inner">
24864      <a class="brand" href="/">
24865        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24866        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
24867      </a>
24868      <div class="nav-right">
24869        <a class="nav-pill" href="/">Home</a>
24870        <div class="nav-dropdown">
24871          <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>
24872          <div class="nav-dropdown-menu">
24873            <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>
24874          </div>
24875        </div>
24876        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24877        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24878        <div class="nav-dropdown">
24879          <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>
24880          <div class="nav-dropdown-menu">
24881            <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>
24882          </div>
24883        </div>
24884        <div class="server-status-wrap" id="server-status-wrap">
24885          <div class="nav-pill server-online-pill" id="server-status-pill">
24886            <span class="status-dot" id="status-dot"></span>
24887            <span id="server-status-label">Server</span>
24888            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24889          </div>
24890          <div class="server-status-tip">
24891            OxideSLOC is running — accessible on your network.
24892            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24893          </div>
24894        </div>
24895        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24896          <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>
24897        </button>
24898        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24899          <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>
24900          <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>
24901        </button>
24902      </div>
24903    </div>
24904  </div>
24905
24906  <div class="page">
24907    <div class="watched-bar">
24908      <div class="watched-bar-left">
24909        <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>
24910        <span class="watched-label">Watched Folders</span>
24911        <div class="watched-chips">
24912          {% if server_mode %}
24913          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24914          {% else %}
24915          {% for dir in watched_dirs %}
24916          <span class="watched-chip">
24917            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24918            <form method="POST" action="/watched-dirs/remove" style="display:contents">
24919              <input type="hidden" name="folder_path" value="{{ dir }}">
24920              <input type="hidden" name="redirect_to" value="/compare-scans">
24921              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
24922            </form>
24923          </span>
24924          {% endfor %}
24925          {% if watched_dirs.is_empty() %}
24926          <span class="watched-none">No folders watched — click Choose to add one</span>
24927          {% endif %}
24928          {% endif %}
24929        </div>
24930      </div>
24931      {% if !server_mode %}
24932      <div class="watched-bar-right">
24933        <button type="button" class="btn" id="add-watched-btn">
24934          <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>
24935          Choose
24936        </button>
24937        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24938          <input type="hidden" name="redirect_to" value="/compare-scans">
24939          <button type="submit" class="btn">&#8635; Refresh</button>
24940        </form>
24941      </div>
24942      {% endif %}
24943    </div>
24944    {% if total_scans > 0 %}
24945    <div class="summary-strip">
24946      <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>
24947      <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>
24948      <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>
24949      <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>
24950    </div>
24951    {% endif %}
24952    <section class="panel">
24953      <div class="panel-header">
24954        <div>
24955          <h1>Compare Scans</h1>
24956          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
24957        </div>
24958        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
24959          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
24960            <button class="btn primary" id="compare-btn" disabled>
24961              <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>
24962              Compare <span class="sel-count" id="sel-count">0</span> Selected
24963            </button>
24964          </div>
24965        </div>
24966      </div>
24967
24968      {% if entries.is_empty() %}
24969      <div class="empty-state">
24970        <strong>No scans yet</strong>
24971        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.
24972      </div>
24973      {% else %}
24974      <div class="filter-row">
24975        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
24976        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24977        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
24978      </div>
24979      <div class="scope-panel hidden" id="scope-panel">
24980        <div class="scope-panel-label">
24981          <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>
24982          Compare scope — choose what to include
24983        </div>
24984        <div class="scope-options" id="scope-options"></div>
24985      </div>
24986      {% if total_scans > 0 %}
24987      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
24988        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
24989          <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>
24990          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
24991        </div>
24992      </div>
24993      {% endif %}
24994      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
24995        <span class="compare-all-label">
24996          <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>
24997          Quick Compare All
24998        </span>
24999      </div>
25000      <div class="table-wrap">
25001        <table id="compare-table">
25002          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
25003          <thead>
25004            <tr id="compare-thead">
25005              <th><div class="col-resize-handle"></div></th>
25006              <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>
25007              <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>
25008              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
25009              <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>
25010              <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>
25011              <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>
25012              <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>
25013              <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>
25014              <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>
25015              <th>Submodules<div class="col-resize-handle"></div></th>
25016            </tr>
25017          </thead>
25018          <tbody id="compare-tbody">
25019            {% for entry in entries %}
25020            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
25021                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
25022                data-project="{{ entry.project_label }}"
25023                data-files="{{ entry.files_analyzed }}"
25024                data-code="{{ entry.code_lines }}"
25025                data-comments="{{ entry.comment_lines }}"
25026                data-blank="{{ entry.blank_lines }}"
25027                data-branch="{{ entry.git_branch }}"
25028                data-commit="{{ entry.git_commit }}"
25029                data-submodules="{{ entry.submodule_names_csv }}">
25030              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
25031              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
25032              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
25033              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
25034              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
25035              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
25036              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
25037              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
25038              <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>
25039              <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>
25040              <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>
25041            </tr>
25042            {% endfor %}
25043          </tbody>
25044        </table>
25045      </div>
25046      <div class="pagination">
25047        <span class="pagination-info" id="pagination-info"></span>
25048        <div class="pagination-btns" id="pagination-btns"></div>
25049        <div class="flex-row">
25050          <span class="per-page-label">Show</span>
25051          <select class="per-page" id="per-page-sel">
25052            <option value="10">10 per page</option>
25053            <option value="25" selected>25 per page</option>
25054            <option value="50">50 per page</option>
25055            <option value="100">100 per page</option>
25056          </select>
25057          <span class="per-page-label" id="page-range-label"></span>
25058        </div>
25059      </div>
25060      {% endif %}
25061    </section>
25062  </div>
25063
25064  <footer class="site-footer">
25065    local code analysis - metrics, history and reports
25066    &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>
25067    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25068    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25069    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25070    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25071  </footer>
25072
25073  <script nonce="{{ csp_nonce }}">
25074    (function () {
25075      // ── Theme ──────────────────────────────────────────────────────────────
25076      var storageKey = 'oxide-sloc-theme';
25077      var body = document.body;
25078      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25079      var toggle = document.getElementById('theme-toggle');
25080      if (toggle) toggle.addEventListener('click', function () {
25081        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25082        body.classList.toggle('dark-theme', next === 'dark');
25083        try { localStorage.setItem(storageKey, next); } catch(e) {}
25084      });
25085
25086      // ── State ─────────────────────────────────────────────────────────────
25087      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
25088      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
25089      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
25090      window._allCompareRows = allRows;
25091
25092      // ── Stat chips ────────────────────────────────────────────────────────
25093      (function() {
25094        var projects = {}, latestTs = '', latestRow = null;
25095        allRows.forEach(function(r) {
25096          var p = r.dataset.project || ''; if (p) projects[p] = true;
25097          var ts = r.dataset.timestamp || '';
25098          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
25099        });
25100        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();}
25101        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>':'');}
25102        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
25103        if (latestRow) {
25104          setChipVal('agg-code', latestRow.dataset.code);
25105          setChipVal('agg-files', latestRow.dataset.files);
25106        }
25107      })();
25108
25109      // ── Branch filter population ──────────────────────────────────────────
25110      (function() {
25111        var branches = {};
25112        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
25113        var sel = document.getElementById('branch-filter');
25114        if (sel) Object.keys(branches).sort().forEach(function(b) {
25115          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
25116        });
25117      })();
25118
25119      // ── Filter ────────────────────────────────────────────────────────────
25120      function getFilteredRows() {
25121        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
25122        var branch = ((document.getElementById('branch-filter') || {}).value || '');
25123        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
25124          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
25125          if (branch && (r.dataset.branch || '') !== branch) return false;
25126          return true;
25127        });
25128      }
25129
25130      // ── Pagination ────────────────────────────────────────────────────────
25131      function renderPage() {
25132        var filtered = getFilteredRows();
25133        var total = filtered.length;
25134        var totalPages = Math.max(1, Math.ceil(total / perPage));
25135        currentPage = Math.min(currentPage, totalPages);
25136        var start = (currentPage - 1) * perPage;
25137        var end = Math.min(start + perPage, total);
25138        var shown = {};
25139        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
25140        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
25141          r.style.display = shown[r.dataset.run] ? '' : 'none';
25142        });
25143        var rl = document.getElementById('page-range-label');
25144        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
25145        var info = document.getElementById('pagination-info');
25146        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
25147        var btns = document.getElementById('pagination-btns');
25148        if (!btns) return;
25149        btns.innerHTML = '';
25150        function makeBtn(lbl, pg, active, disabled) {
25151          var b = document.createElement('button');
25152          b.className = 'pg-btn' + (active ? ' active' : '');
25153          b.textContent = lbl; b.disabled = disabled;
25154          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
25155          return b;
25156        }
25157        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
25158        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
25159        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
25160        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
25161      }
25162
25163      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
25164      window.applyFilters = function() { currentPage = 1; renderPage(); };
25165
25166      // ── Sorting ───────────────────────────────────────────────────────────
25167      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
25168      function doSort(col, type, order) {
25169        var tbody = document.getElementById('compare-tbody');
25170        if (!tbody) return;
25171        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25172        rows.sort(function(a, b) {
25173          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
25174          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
25175          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
25176          return va < vb ? 1 : va > vb ? -1 : 0;
25177        });
25178        rows.forEach(function(r) { tbody.appendChild(r); });
25179        currentPage = 1; renderPage();
25180      }
25181      sortHeaders.forEach(function(th) {
25182        th.addEventListener('click', function(e) {
25183          if (e.target.classList.contains('col-resize-handle')) return;
25184          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
25185          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
25186          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25187          th.classList.add('sort-' + sortOrder);
25188          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
25189          doSort(col, type, sortOrder);
25190        });
25191      });
25192
25193      // Apply default sort (timestamp desc) on initial load
25194      (function() {
25195        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
25196        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
25197      })();
25198
25199      // ── Column resize ─────────────────────────────────────────────────────
25200      (function() {
25201        var table = document.getElementById('compare-table');
25202        if (!table) return;
25203        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
25204        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
25205        ths.forEach(function(th, i) {
25206          var handle = th.querySelector('.col-resize-handle');
25207          if (!handle || !cols[i]) return;
25208          var startX, startW;
25209          handle.addEventListener('mousedown', function(e) {
25210            e.stopPropagation(); e.preventDefault();
25211            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
25212            handle.classList.add('dragging');
25213            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
25214            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
25215            document.addEventListener('mousemove', onMove);
25216            document.addEventListener('mouseup', onUp);
25217          });
25218        });
25219      })();
25220
25221      // ── Reset view ────────────────────────────────────────────────────────
25222      window.resetView = function() {
25223        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
25224        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
25225        sortCol = null; 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        var tbody = document.getElementById('compare-tbody');
25228        if (tbody) {
25229          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25230          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
25231          rows.forEach(function(r) { tbody.appendChild(r); });
25232        }
25233        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
25234        var table = document.getElementById('compare-table');
25235        currentPage = 1; renderPage();
25236        currentPage = 1; renderPage();
25237      };
25238
25239      renderPage();
25240      buildCompareAllBar();
25241
25242      // ── Row selection state ───────────────────────────────────────────────
25243      var selected = [];
25244      var lockedProject = null; // project label of first selected scan
25245
25246      function updateCompareBtn() {
25247        var btn = document.getElementById('compare-btn');
25248        var cnt = document.getElementById('sel-count');
25249        if (!btn) return;
25250        btn.disabled = selected.length < 2;
25251        if (cnt) cnt.textContent = selected.length;
25252      }
25253
25254      function applyProjectLock() {
25255        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25256        allRows.forEach(function(r) {
25257          if (lockedProject === null) {
25258            r.classList.remove('row-locked');
25259          } else {
25260            var proj = r.dataset.project || '';
25261            if (proj !== lockedProject) {
25262              r.classList.add('row-locked');
25263            } else {
25264              r.classList.remove('row-locked');
25265            }
25266          }
25267        });
25268      }
25269
25270      function toggleRow(row) {
25271        if (row.classList.contains('row-locked')) return;
25272        var vid = row.dataset.vid || row.dataset.run;
25273        var idx = selected.indexOf(vid);
25274        if (idx >= 0) {
25275          selected.splice(idx, 1);
25276          row.classList.remove('selected');
25277          var b = document.getElementById('badge-' + vid);
25278          if (b) b.textContent = '';
25279          // Release project lock if nothing selected
25280          if (selected.length === 0) lockedProject = null;
25281        } else {
25282          // Set project lock on first selection
25283          if (selected.length === 0) lockedProject = row.dataset.project || null;
25284          selected.push(vid);
25285          row.classList.add('selected');
25286        }
25287        selected.forEach(function(v, i) {
25288          var b = document.getElementById('badge-' + v);
25289          if (b) b.textContent = i + 1;
25290        });
25291        applyProjectLock();
25292        updateCompareBtn();
25293        buildScopePanel();
25294      }
25295
25296      // ── Compare-All bar ───────────────────────────────────────────────────
25297      function buildCompareAllBar() {
25298        var bar = document.getElementById('compare-all-bar');
25299        if (!bar) return;
25300        // Group all rows by project label.
25301        var groups = {};
25302        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25303        // Use all rows from the source data (not just visible).
25304        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25305        // We need ALL rows across all pages, not just the rendered ones.
25306        // Use the underlying allRows array that the pagination JS also uses.
25307        var sourceRows = window._allCompareRows || allRowsAll;
25308        sourceRows.forEach(function(r) {
25309          var proj = r.dataset.project || '';
25310          var vid = r.dataset.vid || r.dataset.run || '';
25311          if (!proj || !vid) return;
25312          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
25313          groups[proj].ids.push(vid);
25314          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
25315        });
25316        // Build buttons for each project with >= 2 scans.
25317        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
25318        if (!keys.length) { bar.style.display = 'none'; return; }
25319        bar.style.display = 'flex';
25320        // Remove old buttons (keep label).
25321        var oldBtns = bar.querySelectorAll('.compare-all-btn');
25322        oldBtns.forEach(function(b) { b.remove(); });
25323        keys.sort();
25324        keys.forEach(function(proj) {
25325          var g = groups[proj];
25326          var btn = document.createElement('button');
25327          btn.className = 'compare-all-btn';
25328          btn.type = 'button';
25329          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
25330          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
25331          btn.addEventListener('click', function() {
25332            // Sort ids by timestamp (ascending).
25333            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
25334            pairs.sort(function(a, b) { return a.ts - b.ts; });
25335            var sorted = pairs.map(function(p) { return p.id; });
25336            if (sorted.length === 2) {
25337              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
25338            } else {
25339              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
25340            }
25341          });
25342          bar.appendChild(btn);
25343        });
25344      }
25345
25346      // ── Scope panel ───────────────────────────────────────────────────────
25347      var selectedScope = 'all';
25348
25349      function buildScopePanel() {
25350        var panel = document.getElementById('scope-panel');
25351        var opts = document.getElementById('scope-options');
25352        if (!panel || !opts) return;
25353        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25354
25355        // Collect union of submodules from all selected rows.
25356        var allSubs = {};
25357        selected.forEach(function(vid) {
25358          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
25359          if (!row) return;
25360          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
25361        });
25362        var subList = Object.keys(allSubs).sort();
25363        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25364
25365        panel.classList.remove('hidden');
25366        opts.innerHTML = '';
25367
25368        function makeOption(value, label, title) {
25369          var div = document.createElement('div');
25370          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
25371          div.dataset.scopeValue = value;
25372          if (title) div.title = title;
25373          var radio = document.createElement('span');
25374          radio.className = 'scope-option-radio';
25375          var lbl = document.createElement('span');
25376          lbl.textContent = label;
25377          div.appendChild(radio);
25378          div.appendChild(lbl);
25379          div.addEventListener('click', function() {
25380            selectedScope = value;
25381            opts.querySelectorAll('.scope-option').forEach(function(o) {
25382              o.classList.toggle('selected', o.dataset.scopeValue === value);
25383            });
25384          });
25385          return div;
25386        }
25387
25388        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
25389        var sep = document.createElement('span');
25390        sep.className = 'scope-option-sep';
25391        opts.appendChild(sep);
25392        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
25393        subList.forEach(function(s) {
25394          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
25395        });
25396      }
25397
25398      function doCompare() {
25399        if (selected.length < 2) return;
25400        if (selected.length === 2) {
25401          // Two-scan delta (existing flow with scope support).
25402          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
25403          if (selectedScope === 'super') url += '&scope=super';
25404          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25405          window.location.href = url;
25406        } else {
25407          // Multi-scan timeline (N >= 3) — pass scope params too.
25408          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
25409          if (selectedScope === 'super') url += '&scope=super';
25410          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25411          window.location.href = url;
25412        }
25413      }
25414
25415      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
25416      var cbtn = document.getElementById('compare-btn');
25417      if (cbtn) cbtn.addEventListener('click', doCompare);
25418      var pfEl = document.getElementById('project-filter');
25419      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
25420      var bfEl = document.getElementById('branch-filter');
25421      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
25422      var rvBtn = document.getElementById('reset-view-btn');
25423      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
25424      var ppSel = document.getElementById('per-page-sel');
25425      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
25426
25427      var cmpTbody = document.getElementById('compare-tbody');
25428      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
25429        var row = e.target.closest('.compare-row');
25430        if (row) toggleRow(row);
25431      });
25432
25433      (function randomizeWatermarks() {
25434        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25435        if (!wms.length) return;
25436        var placed = [];
25437        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;}
25438        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];}
25439        var half=Math.floor(wms.length/2);
25440        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;});
25441      })();
25442
25443      (function spawnCodeParticles() {
25444        var container = document.getElementById('code-particles');
25445        if (!container) return;
25446        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'];
25447        for (var i = 0; i < 38; i++) {
25448          (function(idx) {
25449            var el = document.createElement('span');
25450            el.className = 'code-particle';
25451            el.textContent = snippets[idx % snippets.length];
25452            var left = Math.random() * 94 + 2;
25453            var top = Math.random() * 88 + 6;
25454            var dur = (Math.random() * 10 + 9).toFixed(1);
25455            var delay = (Math.random() * 18).toFixed(1);
25456            var rot = (Math.random() * 26 - 13).toFixed(1);
25457            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25458            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';
25459            container.appendChild(el);
25460          })(i);
25461        }
25462      })();
25463
25464      // ── Watched folder picker ─────────────────────────────────────────────
25465      (function() {
25466        var btn = document.getElementById('add-watched-btn');
25467        if (!btn) return;
25468        btn.addEventListener('click', function() {
25469          fetch('/pick-directory?kind=reports')
25470            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
25471            .then(function(data) {
25472              if (!data.cancelled && data.selected_path) {
25473                var form = document.createElement('form');
25474                form.method = 'POST';
25475                form.action = '/watched-dirs/add';
25476                var ri = document.createElement('input');
25477                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
25478                var fi = document.createElement('input');
25479                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
25480                form.appendChild(ri); form.appendChild(fi);
25481                document.body.appendChild(form);
25482                form.submit();
25483              }
25484            })
25485            .catch(function(e) { alert('Could not open folder picker: ' + e); });
25486        });
25487      })();
25488
25489      // ── Submodule chip truncation ─────────────────────────────────────────
25490      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
25491        var chips = cell.querySelectorAll('.submod-chip');
25492        var MAX = 4;
25493        if (chips.length <= MAX) return;
25494        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
25495        var badge = document.createElement('span');
25496        badge.className = 'submod-overflow-badge';
25497        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
25498        badge.textContent = '+' + (chips.length - MAX) + ' more';
25499        cell.appendChild(badge);
25500        cell.style.maxHeight = 'none';
25501      });
25502    })();
25503  </script>
25504  <script nonce="{{ csp_nonce }}">
25505  (function(){
25506    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'}];
25507    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);});}
25508    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25509    function init(){
25510      var btn=document.getElementById('settings-btn');if(!btn)return;
25511      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25512      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>';
25513      document.body.appendChild(m);
25514      var g=document.getElementById('scheme-grid');
25515      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);});
25516      var cl=document.getElementById('settings-close');
25517      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);
25518      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');});
25519      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25520      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25521    }
25522    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25523  }());
25524  </script>
25525  <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]';
25526  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;}
25527  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>
25528</body>
25529</html>
25530"##,
25531    ext = "html"
25532)]
25533struct CompareSelectTemplate {
25534    version: &'static str,
25535    entries: Vec<HistoryEntryRow>,
25536    total_scans: usize,
25537    watched_dirs: Vec<String>,
25538    csp_nonce: String,
25539    server_mode: bool,
25540}
25541
25542// ── CompareTemplate ────────────────────────────────────────────────────────────
25543
25544#[derive(Template)]
25545#[template(
25546    source = r##"
25547<!doctype html>
25548<html lang="en">
25549<head>
25550  <meta charset="utf-8">
25551  <meta name="viewport" content="width=device-width, initial-scale=1">
25552  <title>OxideSLOC | Scan Delta</title>
25553  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25554  <style nonce="{{ csp_nonce }}">
25555    :root {
25556      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
25557      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
25558      --nav:#283790; --nav-2:#013e6b;
25559      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
25560      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
25561      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
25562    }
25563    body.dark-theme {
25564      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
25565      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
25566    }
25567    *{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;}
25568    .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);}
25569    .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;}
25570    .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));}
25571    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25572    .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;}
25573    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
25574    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25575    @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; } }
25576    .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;}
25577    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25578    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25579    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25580    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25581    .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;}
25582    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25583    .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);}
25584    .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;}
25585    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25586    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25587    .settings-modal-body{padding:14px 16px 16px;}
25588    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25589    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25590    .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;}
25591    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25592    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25593    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25594    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25595    .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;}
25596    .tz-select:focus{border-color:var(--oxide);}
25597    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
25598    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25599    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
25600    .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;}
25601    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
25602    .hero-body{display:block;}
25603    .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;}
25604    .btn-back:hover{background:var(--line);}
25605    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
25606    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
25607    .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;}
25608    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
25609    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;}
25610    .muted{color:var(--muted);font-size:14px;}
25611    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
25612    .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;}
25613    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
25614    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
25615    .vpill-arrow{font-size:20px;color:var(--muted);}
25616    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
25617    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
25618    .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;}
25619    .delta-card.delta-card-wide{padding:22px 24px;}
25620    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
25621    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
25622    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
25623    .delta-card-from{font-size:15px;color:var(--muted);}
25624    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
25625    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
25626    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
25627    .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%;}
25628    .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;}
25629    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
25630    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
25631    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
25632    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
25633    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
25634    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
25635    .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;}
25636    .meta-card-commit:hover{color:var(--oxide);}
25637    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
25638    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
25639    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
25640    .meta-value{color:var(--text);font-size:13px;}
25641    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
25642    .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;}
25643    .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);}
25644    .delta-card:hover .dc-tip{display:block;}
25645    .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;}
25646    .export-btn:hover{background:var(--line);}
25647    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
25648    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
25649    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
25650    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
25651    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
25652    .delta-card-change.zero{color:var(--muted);background:transparent;}
25653    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
25654    .delta-card-pct.pos{color:var(--pos);}
25655    .delta-card-pct.neg{color:var(--neg);}
25656    .delta-card-pct.zero{color:var(--muted);}
25657    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
25658    .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;}
25659    .insight-card.insight-flag{border-color:var(--oxide);}
25660    .insight-card:hover .dc-tip{display:block;}
25661    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
25662    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
25663    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
25664    .insight-label.flag{color:var(--oxide);}
25665    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
25666    .insight-val.pos{color:var(--pos);}
25667    .insight-val.neg{color:var(--neg);}
25668    .insight-val.high{color:#c0392a;}
25669    .insight-val.med{color:#926000;}
25670    .insight-val.low{color:var(--pos);}
25671    body.dark-theme .insight-val.high{color:#ff6b6b;}
25672    body.dark-theme .insight-val.med{color:#f0c060;}
25673    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
25674    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
25675    .fc-row{display:flex;align-items:center;gap:8px;}
25676    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
25677    .fc-label{color:var(--muted);}
25678    .fc-modified .fc-count{color:#926000;}
25679    .fc-added .fc-count{color:var(--pos);}
25680    .fc-removed .fc-count{color:var(--neg);}
25681    .fc-unchanged .fc-count{color:var(--muted);}
25682    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
25683    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
25684    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
25685    .chip.modified{background:#fff2d8;color:#926000;}
25686    .chip.added{background:#e8f5ed;color:#1a8f47;}
25687    .chip.removed{background:#fdeaea;color:#b33b3b;}
25688    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
25689    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
25690    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
25691    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
25692    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
25693    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
25694    .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;}
25695    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
25696    .tab-btn:hover:not(.active){background:var(--line);}
25697    .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;}
25698    .btn-reset:hover{background:var(--line);}
25699    .table-wrap{width:100%;overflow-x:auto;}
25700    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
25701    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);}
25702    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
25703    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
25704    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
25705    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
25706    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
25707    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
25708    tr:last-child td{border-bottom:none;}
25709    tr:hover td{background:var(--surface-2);}
25710    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
25711    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
25712    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
25713    tr.row-added td{background:rgba(26,143,71,0.04);}
25714    tr.row-removed td{background:rgba(179,59,59,0.06);}
25715    tr.row-modified td{background:rgba(146,96,0,0.04);}
25716    tr.row-unchanged td{color:var(--muted);}
25717    tr.row-unchanged .status-badge{opacity:.65;}
25718    .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;}
25719    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
25720    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
25721    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
25722    .status-badge.modified{background:#fff2d8;color:#926000;}
25723    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
25724    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
25725    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
25726    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
25727    .delta-val{font-weight:700;}
25728    .delta-val.pos{color:var(--pos);}
25729    .delta-val.neg{color:var(--neg);}
25730    .delta-val.zero{color:var(--muted);}
25731    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
25732    .from-to strong{color:var(--text);font-weight:700;}
25733    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
25734    .from-to .ft-absent{color:var(--muted);font-weight:600;}
25735    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25736    .site-footer a{color:var(--muted);}
25737    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;}
25738    body.pdf-mode{background:#fff!important;}
25739    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
25740    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
25741    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25742    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25743    .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;}
25744    .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;}
25745    .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;}
25746    @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));}}
25747    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
25748    .path-link:hover{color:var(--oxide-2);}
25749    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
25750    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
25751    a.vpill-id:hover{color:var(--oxide);}
25752    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
25753    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
25754    .pagination-info{font-size:13px;color:var(--muted);}
25755    .pagination-btns{display:flex;gap:6px;}
25756    .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;}
25757    .pg-btn:hover:not(:disabled){background:var(--line);}
25758    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25759    .pg-btn:disabled{opacity:.35;cursor:default;}
25760    .per-page-label{font-size:13px;color:var(--muted);}
25761    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;}
25762    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25763    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
25764    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
25765    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
25766    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
25767    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
25768    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
25769    .tab-btn.tab-unchanged{color:var(--muted);}
25770    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
25771    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
25772    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
25773    .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;}
25774    .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;}
25775    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
25776    .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;}
25777    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
25778    .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;}
25779    .submod-scope-btn:hover{background:var(--line);}
25780    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25781    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
25782    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
25783    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
25784    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
25785    body.dark-theme .ic-card{background:var(--surface-2);}
25786    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
25787    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
25788    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
25789    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
25790    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
25791    .ic-cb{cursor:pointer;transition:filter .15s;}.ic-cb:hover{filter:brightness(1.12);}
25792    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
25793    .ic-card-h2-row .ic-card-h2{margin:0;}
25794    .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;}
25795    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25796    .chart-metric-btn:hover:not(.active){background:var(--line);}
25797    .chart-wrap{width:100%;overflow-x:auto;}
25798    #cmp-tl-svg{display:block;width:100%;}
25799    .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);}
25800    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
25801    #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;}
25802  </style>
25803</head>
25804<body>
25805  <div class="background-watermarks" aria-hidden="true">
25806    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25807    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25808    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25809    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25810    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812  </div>
25813  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25814  <div class="top-nav">
25815    <div class="top-nav-inner">
25816      <a class="brand" href="/">
25817        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25818        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
25819      </a>
25820      <div class="nav-right">
25821        <a class="nav-pill" href="/">Home</a>
25822        <div class="nav-dropdown">
25823          <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>
25824          <div class="nav-dropdown-menu">
25825            <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>
25826          </div>
25827        </div>
25828        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25829        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25830        <div class="nav-dropdown">
25831          <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>
25832          <div class="nav-dropdown-menu">
25833            <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>
25834          </div>
25835        </div>
25836        <div class="server-status-wrap" id="server-status-wrap">
25837          <div class="nav-pill server-online-pill" id="server-status-pill">
25838            <span class="status-dot" id="status-dot"></span>
25839            <span id="server-status-label">Server</span>
25840            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25841          </div>
25842          <div class="server-status-tip">
25843            OxideSLOC is running — accessible on your network.
25844            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25845          </div>
25846        </div>
25847        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25848          <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>
25849        </button>
25850        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25851          <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>
25852          <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>
25853        </button>
25854      </div>
25855    </div>
25856  </div>
25857
25858  <div class="page">
25859    <section class="hero">
25860      <div class="hero-header">
25861        <div>
25862          <h1 class="delta-title">Scan Delta</h1>
25863          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
25864          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
25865            {% if let Some(sub) = active_submodule %}
25866            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
25867            {% else if super_scope_active %}
25868            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
25869            {% else %}
25870            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
25871            {% endif %}
25872            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
25873          </div>
25874        </div>
25875        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
25876          <a class="btn-back" href="/compare-scans">
25877            <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>
25878            Compare Scans
25879          </a>
25880          <div class="export-group" style="margin-top:12px;">
25881            <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>
25882            <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>
25883          </div>
25884        </div>
25885      </div>
25886      {% if has_any_submodule_data %}
25887      <div class="submod-scope-bar">
25888        <span class="submod-scope-label">
25889          <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>
25890          Scope:
25891        </span>
25892        <div class="submod-scope-divider"></div>
25893        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
25894           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
25895           title="All files — super-repo and all submodules combined">Full scan</a>
25896        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
25897           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
25898           title="Only files that are not part of any submodule">Super-repo only</a>
25899        {% for sub in submodule_options %}
25900        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
25901           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
25902           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
25903        {% endfor %}
25904      </div>
25905      {% endif %}
25906      <div class="hero-body">
25907      <div class="meta-strip">
25908        <div class="delta-card delta-card-meta">
25909          <div class="meta-card-header">
25910            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
25911            <div class="meta-card-project-col">
25912              <div class="meta-card-project">{{ project_name }}</div>
25913              {% if has_any_submodule_data %}
25914              {% if let Some(sub) = active_submodule %}
25915              <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>
25916              {% else if super_scope_active %}
25917              <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>
25918              {% else %}
25919              <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>
25920              {% endif %}
25921              {% endif %}
25922            </div>
25923          </div>
25924          {% if !baseline_git_commit.is_empty() %}
25925          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
25926          {% else %}
25927          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
25928          {% endif %}
25929          <div class="meta-card-rows">
25930            <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>
25931            <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>
25932            <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>
25933            <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>
25934            {% if let Some(tags) = baseline_git_tags %}
25935            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25936            {% endif %}
25937          </div>
25938        </div>
25939        <div class="delta-card delta-card-meta">
25940          <div class="meta-card-header">
25941            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
25942            <div class="meta-card-project-col">
25943              <div class="meta-card-project">{{ project_name }}</div>
25944              {% if has_any_submodule_data %}
25945              {% if let Some(sub) = active_submodule %}
25946              <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>
25947              {% else if super_scope_active %}
25948              <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>
25949              {% else %}
25950              <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>
25951              {% endif %}
25952              {% endif %}
25953            </div>
25954          </div>
25955          {% if !current_git_commit.is_empty() %}
25956          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
25957          {% else %}
25958          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
25959          {% endif %}
25960          <div class="meta-card-rows">
25961            <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>
25962            <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>
25963            <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>
25964            <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>
25965            {% if let Some(tags) = current_git_tags %}
25966            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25967            {% endif %}
25968          </div>
25969        </div>
25970      </div>
25971      <div class="delta-strip">
25972        <div class="delta-card">
25973          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
25974          <div class="delta-card-label">Code lines</div>
25975          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
25976          <div class="delta-card-to">{{ current_code_fmt }}</div>
25977          {% 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>
25978          {% 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>
25979          {% else %}<div class="delta-card-pct zero">±0%</div>
25980          {% endif %}
25981        </div>
25982        <div class="delta-card">
25983          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
25984          <div class="delta-card-label">Files analyzed</div>
25985          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
25986          <div class="delta-card-to">{{ current_files_fmt }}</div>
25987          {% 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>
25988          {% 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>
25989          {% else %}<div class="delta-card-pct zero">±0%</div>
25990          {% endif %}
25991        </div>
25992        <div class="delta-card">
25993          <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>
25994          <div class="delta-card-label">Comment lines</div>
25995          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
25996          <div class="delta-card-to">{{ current_comments_fmt }}</div>
25997          {% 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>
25998          {% 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>
25999          {% else %}<div class="delta-card-pct zero">±0%</div>
26000          {% endif %}
26001        </div>
26002        {{ coverage_delta_card|safe }}
26003        <div class="delta-card delta-card-wide">
26004          <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>
26005          <div class="delta-card-label">File changes</div>
26006          <div class="file-changes-grid">
26007            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
26008            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
26009            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
26010            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
26011          </div>
26012        </div>
26013      </div>
26014      <div class="insights-panel">
26015        <div class="insight-card">
26016          <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>
26017          <div class="insight-label">Lines Added</div>
26018          <div class="insight-val pos">+{{ code_lines_added }}</div>
26019          <div class="insight-sub">New or grown source lines</div>
26020        </div>
26021        <div class="insight-card">
26022          <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>
26023          <div class="insight-label">Lines Removed</div>
26024          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
26025          <div class="insight-sub">Deleted or shrunk source lines</div>
26026        </div>
26027        <div class="insight-card">
26028          <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>
26029          <div class="insight-label">Churn Rate</div>
26030          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
26031          <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>
26032        </div>
26033        {% if scope_flag %}
26034        <div class="insight-card insight-flag">
26035          <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>
26036          <div class="insight-label flag">Scope Signal</div>
26037          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
26038          <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>
26039        </div>
26040        {% endif %}
26041      </div>
26042      </div>
26043    </section>
26044
26045    <section class="panel" id="inline-charts-section">
26046      <div class="panel-title">Scan Delta Charts</div>
26047      <div class="ic-grid">
26048        <div class="ic-card" style="grid-column:span 2">
26049          <div class="ic-card-h2-row">
26050            <span class="ic-card-h2">Timeline</span>
26051            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
26052              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
26053              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
26054              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
26055              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
26056              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
26057            </div>
26058          </div>
26059          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
26060        </div>
26061        <div class="ic-card">
26062          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
26063          <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>
26064          <div id="ic-c1"></div>
26065        </div>
26066        <div class="ic-card" id="ic-lang-card">
26067          <div class="ic-card-h2">Language Code Delta</div>
26068          <div id="ic-c3"></div>
26069        </div>
26070        <div class="ic-card">
26071          <div class="ic-card-h2">Delta by Metric</div>
26072          <div id="ic-c2"></div>
26073        </div>
26074        <div class="ic-card">
26075          <div class="ic-card-h2">File Change Distribution</div>
26076          <div id="ic-c4"></div>
26077        </div>
26078      </div>
26079    </section>
26080
26081    <section class="panel">
26082      <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>
26083      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
26084        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
26085          <button class="tab-btn tab-all active" data-filter="all">All ({{ files_modified + files_added + files_removed + files_unchanged }})</button>
26086          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
26087          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
26088          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
26089          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
26090        </div>
26091        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
26092          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
26093          <div class="export-group">
26094            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
26095            <button type="button" class="export-btn" id="delta-csv-btn">
26096              <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>
26097              CSV
26098            </button>
26099            <button type="button" class="export-btn" id="delta-xls-btn">
26100              <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>
26101              Excel
26102            </button>
26103          </div>
26104        </div>
26105      </div>
26106
26107      <div class="table-wrap">
26108      <table id="delta-table">
26109        <colgroup>
26110          <col>
26111          <col>
26112          <col>
26113          <col>
26114          <col>
26115          <col>
26116          <col>
26117        </colgroup>
26118        <thead>
26119          <tr id="delta-thead">
26120            <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>
26121            <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>
26122            <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>
26123            <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>
26124            <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>
26125            <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>
26126            <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>
26127          </tr>
26128        </thead>
26129        <tbody id="delta-tbody">
26130          {% for row in file_rows %}
26131          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
26132              data-path="{{ row.relative_path }}"
26133              data-language="{{ row.language }}"
26134              data-baseline-code="{{ row.baseline_code }}"
26135              data-current-code="{{ row.current_code }}"
26136              data-code-delta="{{ row.code_delta_str }}"
26137              data-comment-delta="{{ row.comment_delta_str }}"
26138              data-total-delta="{{ row.total_delta_str }}"
26139              data-orig-idx="">
26140            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
26141            <td class="hide-sm">{{ row.language }}</td>
26142            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
26143            <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>
26144            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
26145            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
26146            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
26147          </tr>
26148          {% endfor %}
26149        </tbody>
26150      </table>
26151      </div>
26152      <div class="pagination">
26153        <span class="pagination-info" id="pg-range-label"></span>
26154        <div class="pagination-btns" id="pg-btns"></div>
26155        <div class="flex-row">
26156          <span class="per-page-label">Show</span>
26157          <select class="per-page" id="per-page-sel">
26158            <option value="10">10 per page</option>
26159            <option value="25" selected>25 per page</option>
26160            <option value="50">50 per page</option>
26161            <option value="100">100 per page</option>
26162          </select>
26163        </div>
26164      </div>
26165    </section>
26166  </div>
26167
26168  <div id="ic-tt"></div>
26169
26170  <footer class="site-footer">
26171    local code analysis - metrics, history and reports
26172    &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>
26173    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26174    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26175    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26176    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26177  </footer>
26178
26179  <script nonce="{{ csp_nonce }}">
26180    (function () {
26181      var storageKey = 'oxide-sloc-theme';
26182      var body = document.body;
26183      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26184      var toggle = document.getElementById('theme-toggle');
26185      if (toggle) toggle.addEventListener('click', function () {
26186        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26187        body.classList.toggle('dark-theme', next === 'dark');
26188        try { localStorage.setItem(storageKey, next); } catch(e) {}
26189      });
26190
26191      (function randomizeWatermarks() {
26192        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26193        if (!wms.length) return;
26194        var placed = [];
26195        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;}
26196        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];}
26197        var half=Math.floor(wms.length/2);
26198        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;});
26199      })();
26200
26201      (function spawnCodeParticles() {
26202        var container = document.getElementById('code-particles');
26203        if (!container) return;
26204        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'];
26205        for (var i = 0; i < 38; i++) {
26206          (function(idx) {
26207            var el = document.createElement('span');
26208            el.className = 'code-particle';
26209            el.textContent = snippets[idx % snippets.length];
26210            var left = Math.random() * 94 + 2;
26211            var top = Math.random() * 88 + 6;
26212            var dur = (Math.random() * 10 + 9).toFixed(1);
26213            var delay = (Math.random() * 18).toFixed(1);
26214            var rot = (Math.random() * 26 - 13).toFixed(1);
26215            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26216            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';
26217            container.appendChild(el);
26218          })(i);
26219        }
26220      })();
26221    })();
26222
26223    var activeStatusFilter = 'all';
26224    var deltaPerPage = 25, deltaCurrPage = 1;
26225
26226    function openFolder(path) {
26227      fetch('/open-path?path=' + encodeURIComponent(path))
26228        .then(function (r) { return r.json(); })
26229        .then(function (d) {
26230          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
26231        })
26232        .catch(function () {});
26233    }
26234
26235    function getDeltaFilteredRows() {
26236      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
26237        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
26238      });
26239    }
26240
26241    function renderDeltaPage() {
26242      var filtered = getDeltaFilteredRows();
26243      var total = filtered.length;
26244      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
26245      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
26246      var start = (deltaCurrPage - 1) * deltaPerPage;
26247      var end = Math.min(start + deltaPerPage, total);
26248      var shownSet = {};
26249      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
26250      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
26251        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
26252      });
26253      var rl = document.getElementById('pg-range-label');
26254      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total + ' files' : 'No results';
26255      var btns = document.getElementById('pg-btns');
26256      if (!btns) return;
26257      btns.innerHTML = '';
26258      if (totalPages <= 1) return;
26259      function makeBtn(lbl, pg, active, disabled) {
26260        var b = document.createElement('button');
26261        b.className = 'pg-btn' + (active ? ' active' : '');
26262        b.textContent = lbl; b.disabled = disabled;
26263        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
26264        return b;
26265      }
26266      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
26267      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26268      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
26269      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
26270    }
26271
26272    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
26273
26274    function filterRows(status, btn) {
26275      activeStatusFilter = status;
26276      deltaCurrPage = 1;
26277      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
26278        b.classList.remove('active');
26279      });
26280      if (btn) btn.classList.add('active');
26281      renderDeltaPage();
26282    }
26283
26284    // ── Sorting ──────────────────────────────────────────────────────────────
26285    var sortCol = null, sortOrder = 'asc';
26286    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
26287    (function() {
26288      var tbody = document.getElementById('delta-tbody');
26289      if (!tbody) return;
26290      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26291      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
26292    })();
26293
26294    function parseDeltaNum(str) {
26295      if (!str || str === '\u2014') return 0;
26296      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
26297    }
26298
26299    sortHeaders.forEach(function(th) {
26300      th.addEventListener('click', function(e) {
26301        if (e.target.classList.contains('col-resize-handle')) return;
26302        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26303        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26304        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26305        th.classList.add('sort-' + sortOrder);
26306        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
26307        var tbody = document.getElementById('delta-tbody');
26308        if (!tbody) return;
26309        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26310        rows.sort(function(a, b) {
26311          var va, vb;
26312          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
26313          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
26314          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
26315          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
26316          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26317          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26318          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26319          else { va = ''; vb = ''; }
26320          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26321          return va < vb ? 1 : va > vb ? -1 : 0;
26322        });
26323        rows.forEach(function(r) { tbody.appendChild(r); });
26324        deltaCurrPage = 1;
26325        renderDeltaPage();
26326        var activeBtn = document.querySelector('.tab-btn.active');
26327        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26328        if (activeBtn) activeBtn.classList.add('active');
26329      });
26330    });
26331
26332    // ── Column resize ─────────────────────────────────────────────────────────
26333    (function() {
26334      var table = document.getElementById('delta-table');
26335      if (!table) return;
26336      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26337      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
26338      ths.forEach(function(th, i) {
26339        var handle = th.querySelector('.col-resize-handle');
26340        if (!handle || !cols[i]) return;
26341        var startX, startW;
26342        handle.addEventListener('mousedown', function(e) {
26343          e.stopPropagation(); e.preventDefault();
26344          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26345          handle.classList.add('dragging');
26346          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26347          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26348          document.addEventListener('mousemove', onMove);
26349          document.addEventListener('mouseup', onUp);
26350        });
26351      });
26352    })();
26353
26354    // ── Reset ─────────────────────────────────────────────────────────────────
26355    window.resetDeltaTable = function() {
26356      sortCol = null; sortOrder = 'asc';
26357      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26358      var tbody = document.getElementById('delta-tbody');
26359      if (tbody) {
26360        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26361        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26362        rows.forEach(function(r) { tbody.appendChild(r); });
26363      }
26364      var table = document.getElementById('delta-table');
26365      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26366      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
26367      activeStatusFilter = 'all';
26368      deltaCurrPage = 1;
26369      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26370      var allBtn = document.querySelector('.tab-btn');
26371      if (allBtn) allBtn.classList.add('active');
26372      renderDeltaPage();
26373    };
26374
26375    renderDeltaPage();
26376
26377    // Compact number formatter (shared by the delta table; charts define their own locally)
26378    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();}
26379    function fmtFull(n){return Number(n).toLocaleString();}
26380
26381    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
26382    function fmtFromTo() {
26383      var tbody = document.getElementById('delta-tbody');
26384      if (!tbody) return;
26385      tbody.querySelectorAll('.delta-row').forEach(function(row) {
26386        var status = row.dataset.status || '';
26387        var ft = row.querySelector('.from-to');
26388        if (!ft) return;
26389        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
26390        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
26391        var strongs = ft.querySelectorAll('strong');
26392        // Apply fmt() to non-absent strong values
26393        strongs.forEach(function(el) {
26394          var n = parseInt(el.textContent, 10);
26395          if (!isNaN(n)) el.textContent = fmt(n);
26396        });
26397        // Safety: force dash for genuinely absent sides
26398        if (status === 'added' && bv === 0) {
26399          var bs = ft.querySelector('strong:first-of-type');
26400          if (bs && bs.textContent === '0') {
26401            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
26402          }
26403        }
26404        if (status === 'removed' && cv === 0) {
26405          var cs = ft.querySelector('strong:last-of-type');
26406          if (cs && cs.textContent === '0') {
26407            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
26408          }
26409        }
26410      });
26411    }
26412    fmtFromTo();
26413
26414    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
26415    (function() {
26416      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
26417        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
26418      });
26419      var resetBtn = document.getElementById('delta-reset-btn');
26420      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
26421      var csvBtn = document.getElementById('delta-csv-btn');
26422      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
26423      var xlsBtn = document.getElementById('delta-xls-btn');
26424      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
26425      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
26426      function sdFetchUri(path) {
26427        return fetch(path).then(function(r){return r.blob();}).then(function(b){
26428          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
26429        }).catch(function(){return '';});
26430      }
26431      function sdInlineImgs(html, cb) {
26432        var paths=[], seen={};
26433        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
26434        if(!paths.length){cb(html);return;}
26435        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
26436          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
26437          .catch(function(){cb(html);});
26438      }
26439      function buildFullPageHtml(pdfMode) {
26440        if(pdfMode) document.body.classList.add('pdf-mode');
26441        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
26442        renderDeltaPage();
26443        var html = document.documentElement.outerHTML;
26444        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
26445        if(pdfMode) document.body.classList.remove('pdf-mode');
26446        return html;
26447      }
26448      var chartsBtn = document.getElementById('delta-charts-btn');
26449      if (chartsBtn) chartsBtn.addEventListener('click', function() {
26450        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26451        sdInlineImgs(buildFullPageHtml(false), function(html) {
26452          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26453          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26454          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26455          btn.disabled=false;btn.innerHTML=orig;
26456        });
26457      });
26458      var pageHtmlBtn = document.getElementById('page-export-html-btn');
26459      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
26460        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26461        sdInlineImgs(buildFullPageHtml(false), function(html) {
26462          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26463          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26464          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26465          btn.disabled=false;btn.innerHTML=orig;
26466        });
26467      });
26468      // PDF export — clean document-style report, not a web page screenshot
26469      function buildDeltaPdfHtml() {
26470        var sd=_sd, dr=getDeltaExportRows();
26471        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
26472        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
26473        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
26474        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
26475        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26476        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();}
26477        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
26478        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>';}
26479        var lm={};
26480        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;});
26481        var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,15);
26482        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
26483        var css='body{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
26484          '.hdr{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}'+
26485          '.brand{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}'+
26486          '.title{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}'+
26487          '.proj{font-size:12px;color:#99aabb;margin-top:3px;}'+
26488          '.hr{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}'+
26489          '.body{padding:18px 24px;}'+
26490          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;}'+
26491          '.sc{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}'+
26492          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
26493          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
26494          '.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;}'+
26495          '.meta>div{flex:1 1 0;}'+
26496          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:4px;font-size:15px;}'+
26497          '.sec{margin-bottom:18px;}'+
26498          '.sh{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}'+
26499          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
26500          'th{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;}'+
26501          'td{border-bottom:1px solid #eee;padding:5px 10px;vertical-align:middle;}'+
26502          'tr:nth-child(even) td{background:#faf8f6;}'+
26503          '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:16px;}';
26504        var fileRows=dr.slice(0,200).map(function(r){
26505          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
26506          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
26507            '<td style="'+ss+'">'+esc(st)+'</td>'+
26508            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
26509            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
26510            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
26511        }).join('');
26512        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>':'';
26513        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('');
26514        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
26515          '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Scan Delta</div><div class="proj">'+esc(projName)+'</div></div>'+
26516          '<div class="hr">'+esc(_blabel)+'<br>'+esc(_clabel)+'<br>Generated: '+esc(now)+'</div></div>'+
26517          '<div class="body">'+
26518          '<div class="sg">'+
26519          '<div class="sc"><div class="sv">'+delt(sd.cd)+'</div><div class="sl">Code Lines \u0394</div></div>'+
26520          '<div class="sc"><div class="sv">'+delt(sd.fd)+'</div><div class="sl">Files \u0394</div></div>'+
26521          '<div class="sc"><div class="sv">'+delt(sd.cmd)+'</div><div class="sl">Comment Lines \u0394</div></div>'+
26522          '<div class="sc"><div class="sv" style="color:#111">'+fmtN(tfTotal)+'</div><div class="sl">Total Files</div></div>'+
26523          '</div>'+
26524          '<div class="meta">'+
26525          '<div><div class="ml">Baseline Code</div><div class="mv">'+fullN(sd.bc)+'</div></div>'+
26526          '<div><div class="ml">Current Code</div><div class="mv">'+fullN(sd.cc)+'</div></div>'+
26527          '<div><div class="ml">Modified</div><div class="mv">'+fullN(sd.fm)+'</div></div>'+
26528          '<div><div class="ml">Added</div><div class="mv" style="color:#2a6846">+'+fullN(sd.fa)+'</div></div>'+
26529          '<div><div class="ml">Removed</div><div class="mv" style="color:#b23030">-'+fullN(sd.fr)+'</div></div>'+
26530          '<div><div class="ml">Unchanged</div><div class="mv">'+fullN(sd.fu)+'</div></div>'+
26531          '</div>'+
26532          (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>':'')+
26533          '<div class="sec"><p class="sh">File Delta ('+fmtN(dr.length)+' files)</p>'+
26534          '<table><thead><tr><th>File</th><th>Language</th><th>Status</th>'+
26535          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
26536          '</tr></thead><tbody>'+fileRows+more+'</tbody></table></div>'+
26537          '</div>'+
26538          '<div class="ftr"><span>oxide-sloc v{{ version }}</span><span>Scan Delta Report</span>'+
26539          '<span>'+esc(sd.bid)+' \u2192 '+esc(sd.cid)+'</span></div>'+
26540          '</body></html>';
26541      }
26542      function doDeltaPdf(btn) {
26543        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
26544        var html=buildDeltaPdfHtml();
26545        fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:html,filename:getExportFilename('pdf')})})
26546          .then(function(r){if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();})
26547          .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);})
26548          .catch(function(e){alert('PDF export failed: '+e.message);})
26549          .finally(function(){btn.disabled=false;btn.innerHTML=orig;});
26550      }
26551      var pdfBtn = document.getElementById('delta-pdf-btn');
26552      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
26553      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
26554      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
26555      if (location.protocol === 'file:') {
26556        [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'; } });
26557        [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'; } });
26558      }
26559      var ppSel = document.getElementById('per-page-sel');
26560      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
26561      var pathLink = document.getElementById('project-path-link');
26562      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
26563    })();
26564
26565    // ── Export helpers ────────────────────────────────────────────────────────
26566    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
26567    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26568    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);}
26569    function slocMakeXlsx(fname,sd,dr){
26570      var enc=new TextEncoder();
26571      // CRC-32 table
26572      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;}
26573      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;}
26574      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26575      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26576      // Shared string table
26577      var ss=[],si={};
26578      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
26579      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26580      // Worksheet builder — each WS() call gets its own row counter R
26581      function WS(){
26582        var R=0,buf=[];
26583        function cl(c){return String.fromCharCode(65+c);}
26584        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
26585          '<v>'+S(v)+'</v></c>';}
26586        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
26587          (st?' s="'+st+'"':'')+'>'+
26588          '<v>'+(+v)+'</v></c>';}
26589        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
26590        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26591          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
26592          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
26593          '<sheetFormatPr defaultRowHeight="15"/>'+
26594          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
26595        return{sc:sc,nc:nc,row:row,xml:xml};
26596      }
26597      // Language breakdown
26598      var lm={};
26599      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;});
26600      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
26601      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
26602      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
26603      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
26604      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26605      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26606      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):'';}
26607      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
26608      // Summary sheet
26609      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
26610      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
26611      r1(s1(0,proj,2));
26612      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
26613      r1('');
26614      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
26615      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))));
26616      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))));
26617      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))));
26618      r1('');
26619      r1(s1(0,'FILE CHANGES',8));
26620      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
26621      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
26622      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
26623      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
26624      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
26625      if(langs.length){
26626        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
26627        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
26628        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)));});
26629      }
26630      r1('');r1(s1(0,'SCAN METADATA',8));
26631      r1(s1(1,_blabel)+s1(2,_clabel));
26632      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
26633      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
26634      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"/>');
26635      // File Delta sheet
26636      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
26637      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));
26638      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)));});
26639      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
26640      // Shared strings XML
26641      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26642        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
26643        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
26644      // XLSX file map
26645      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26646      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>',
26647        '_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>',
26648        '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>',
26649        '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>',
26650        '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>',
26651        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
26652      // ZIP packer — STORED (no compression), compatible with all XLSX readers
26653      var zparts=[],zcds=[],zoff=0,znf=0;
26654      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
26655       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
26656      ].forEach(function(name){
26657        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26658        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]);
26659        var entry=new Uint8Array(lha.length+nb.length+sz);
26660        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26661        zparts.push(entry);
26662        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));
26663        var cde=new Uint8Array(cda.length+nb.length);
26664        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26665        zcds.push(cde);zoff+=entry.length;znf++;
26666      });
26667      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26668      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]);
26669      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26670      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26671      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26672      zout.set(new Uint8Array(ea),zpos);
26673      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
26674      var xurl=URL.createObjectURL(xblob);
26675      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
26676      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
26677      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
26678    }
26679    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;');}
26680    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
26681    function getExportFilename(ext){return _exportBase+'.'+ext;}
26682
26683    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 %}};
26684    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;}
26685    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
26686    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
26687    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26688    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26689    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):'';}
26690    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
26691    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)]];}
26692    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
26693    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;}
26694    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
26695    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
26696
26697    // ── Chart HTML report ─────────────────────────────────────────────────────
26698    function slocChartReport(fname, sd, dr) {
26699      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
26700      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26701      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26702      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();}
26703      function px(n){return Math.round(n);}
26704      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
26705      // Language map
26706      var lm={};
26707      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;});
26708      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26709
26710      // Builds onmouse* attrs for interactive tooltip on each SVG element
26711      function barTT(label,val){
26712        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
26713      }
26714
26715      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
26716      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'}];
26717      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26718      var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14;
26719      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26720      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26721      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"/>';}
26722      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26723      c1mets.forEach(function(m,i){
26724        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26725        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26726        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>';
26727        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))+'/>';
26728        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>';
26729        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))+'/>';
26730        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>';
26731        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>';
26732        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>';
26733      });
26734      c1+='</svg>';
26735
26736      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
26737      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'}];
26738      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26739      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
26740      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
26741      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26742      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26743      mets.forEach(function(m,i){
26744        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
26745        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
26746        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
26747        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>';
26748        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
26749        if(bw>=52){
26750          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>';
26751        }else{
26752          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
26753          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>';
26754        }
26755      });
26756      c2+='</svg>';
26757
26758      // ── Chart 3: Language Code Delta ─────────────────────────────────────
26759      var c3='';
26760      if(langs.length){
26761        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26762        var C3W=550,c3LW=124,c3FW=52;
26763        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
26764        var L3rH=30,C3H=langs.length*L3rH+20;
26765        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26766        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26767        langs.forEach(function(l,i){
26768          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
26769          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
26770          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26771          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26772          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':''))+'/>';
26773          if(bw>=48){
26774            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>';
26775          }else{
26776            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
26777            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>';
26778          }
26779          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>';
26780        });
26781        c3+='</svg>';
26782      }
26783
26784      // ── Chart 4: File Change Donut — centered pie with legend below
26785      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;});
26786      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26787      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26788      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">';
26789      var ang=-Math.PI/2;
26790      segs.forEach(function(s){
26791        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26792        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
26793        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
26794        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
26795        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
26796        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)+'%')+'/>';
26797        ang+=sw;
26798      });
26799      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>';
26800      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26801      segs.forEach(function(s,i){
26802        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26803        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
26804        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>';
26805      });
26806      c4+='</svg>';
26807
26808      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
26809      var ttJs='var tt=document.getElementById("ox-tt");'+
26810        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
26811        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
26812        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
26813        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
26814        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
26815        'function oxHT(){tt.style.display="none";}';
26816
26817      // body max-width keeps charts from inflating beyond design dimensions on
26818      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
26819      // each chart's height blows up proportionally, breaking the one-page layout.
26820      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;}'+
26821        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
26822        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
26823        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
26824        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
26825        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
26826        'svg{display:block;}'+
26827        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
26828        '#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;}'+
26829        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
26830      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
26831        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26832        '<div id="ox-tt"><\/div>'+
26833        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
26834        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
26835        '<div class="two-col">'+
26836        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
26837        '<div class="leg">'+
26838        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26839        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26840        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
26841        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
26842        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
26843        '<\/div>'+
26844        '<div class="two-col">'+
26845        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
26846        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
26847        '<\/div>'+
26848        '<script>'+ttJs+'<\/script>'+
26849        '<\/body><\/html>';
26850      slocDownload(html, fname, 'text/html;charset=utf-8;');
26851    }
26852    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
26853    window.buildDeltaChartsHtml = function() {
26854      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26855      var sd=_sd;
26856      var projEl=document.querySelector('[data-folder]');
26857      var proj=projEl?projEl.getAttribute('data-folder'):'';
26858      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
26859      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
26860      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
26861      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
26862      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";}';
26863      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);}';
26864      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26865        '<div id="ox-tt"><\/div>'+
26866        '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26867        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' → '+esc(sd.cts||'')+'<\/p>'+
26868        '<div class="two-col">'+
26869        '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26870        '<div class="leg"><span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26871        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26872        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
26873        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
26874        '<\/div>'+
26875        '<div class="two-col">'+
26876        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
26877        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
26878        '<\/div>'+
26879        '<script>'+ttJs+'<\/script>'+
26880        '<\/body><\/html>';
26881    };
26882    // ── Inline delta charts ────────────────────────────────────────────────────
26883    var _icTT=document.getElementById('ic-tt');
26884    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
26885    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';};
26886    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
26887    window.addEventListener('blur',function(){window.icHT();});
26888    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
26889    (function(){
26890      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
26891      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26892      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();}
26893      function px(n){return Math.round(n);}
26894      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26895      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
26896      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);});}
26897      var dr=getDeltaExportRows(),sd=_sd,lm={};
26898      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;});
26899      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26900      // Chart 1: Baseline vs Current grouped bars
26901      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'}];
26902      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26903      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;
26904      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26905      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"/>';}
26906      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26907      c1mets.forEach(function(m,i){
26908        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26909        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26910        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>';
26911        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"/>';
26912        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>';
26913        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"/>';
26914        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>';
26915        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>';
26916        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>';
26917      });
26918      c1+='</svg>';
26919      // Chart 2: Delta by Metric
26920      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'}];
26921      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26922      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;
26923      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26924      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26925      mets.forEach(function(m,i){
26926        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);
26927        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>';
26928        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
26929        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>';}
26930        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>';}
26931      });
26932      c2+='</svg>';
26933      // Chart 3: Language Code Delta
26934      var c3='';
26935      if(langs.length){
26936        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26937        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;
26938        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26939        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26940        langs.forEach(function(l,i){
26941          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);
26942          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26943          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"/>';
26944          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>';}
26945          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>';}
26946          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>';
26947        });
26948        c3+='</svg>';
26949      }
26950      // Chart 4: File Change Donut — centered pie with legend below
26951      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;});
26952      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26953      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26954      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;
26955      if(segs.length===1){
26956        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
26957        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
26958      } else {
26959        segs.forEach(function(s){
26960          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26961          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);
26962          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);
26963          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"/>';
26964          ang+=sw;
26965        });
26966      }
26967      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>';
26968      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26969      segs.forEach(function(s,i){
26970        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26971        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;"/>';
26972        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>';
26973      });
26974      c4+='</svg>';
26975      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
26976      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
26977      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);}
26978      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
26979      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
26980
26981      // Compare Timeline chart (Baseline vs Current, 2 points)
26982      (function() {
26983        var activeCmpMetric='code';
26984        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
26985        function renderCmpTL(metric) {
26986          var svg=document.getElementById('cmp-tl-svg');if(!svg)return;
26987          var W=svg.getBoundingClientRect().width||800,H=280;
26988          svg.setAttribute('height',H);
26989          var pad={l:62,r:20,t:32,b:72};
26990          var dark=document.body.classList.contains('dark-theme');
26991          var cmpPts=[
26992            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
26993            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
26994          ];
26995          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
26996          var valid=pts.filter(function(v){return v!=null;});
26997          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;}
26998          var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
26999          if(minV===maxV){minV=Math.max(0,minV-1);maxV=maxV+1;}
27000          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
27001          var cx0=pad.l,cx1=pad.l+plotW;
27002          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27003          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27004          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
27005          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
27006          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
27007          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();}
27008          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27009          var parts=[];
27010          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
27011          for(var gi=0;gi<5;gi++){
27012            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
27013            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
27014            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
27015          }
27016          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+'"/>');
27017          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"/>');
27018          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
27019                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
27020          dotPts.forEach(function(pt){
27021            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>');
27022            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"/>');
27023            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>');
27024            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>');
27025          });
27026          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
27027          svg.setAttribute('viewBox','0 0 '+W+' '+H);
27028          svg.innerHTML=parts.join('');
27029          // Hover: crosshair + tooltip (matches multi-scan timeline)
27030          var cmpTT=document.getElementById('ic-tt');
27031          svg.onmousemove=function(e){
27032            var rect=svg.getBoundingClientRect();
27033            var scaleX=W/rect.width;
27034            var mouseX=(e.clientX-rect.left)*scaleX;
27035            var nearest=-1,minDist=Infinity;
27036            var cxArr=[cx0,cx1];
27037            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;}}
27038            if(nearest<0)return;
27039            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
27040            var xhair=svg.querySelector('.cmp-xhair');
27041            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
27042            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"/>';
27043            if(!cmpTT)return;
27044            var clbl=cmpPts[nearest].label;
27045            var scanLbl=nearest===0?'Baseline':'Current';
27046            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>';
27047            var bx=rect.left+(nc/W*rect.width)+18;
27048            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
27049            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
27050          };
27051          svg.onmouseleave=function(){
27052            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
27053            if(cmpTT)cmpTT.style.display='none';
27054          };
27055        }
27056        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
27057          btn.addEventListener('click',function(){
27058            activeCmpMetric=this.dataset.cmpMetric;
27059            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
27060            this.classList.add('active');
27061            renderCmpTL(activeCmpMetric);
27062          });
27063        });
27064        var ttgl=document.getElementById('theme-toggle');
27065        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);},0);});
27066        if(typeof ResizeObserver!=='undefined'){
27067          var cmpSvg=document.getElementById('cmp-tl-svg');
27068          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
27069        }
27070        renderCmpTL(activeCmpMetric);
27071      })();
27072
27073      // HTML legend hover -> highlight matching SVG bars within the SAME card only
27074      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
27075        var metric=leg.getAttribute('data-highlight');
27076        var parentCard=leg.closest('.ic-card');
27077        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
27078        if(!chartEl)return;
27079        leg.addEventListener('mouseenter',function(){
27080          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
27081            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';}
27082            else{x.style.opacity='0.28';}
27083          });
27084        });
27085        leg.addEventListener('mouseleave',function(){
27086          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
27087        });
27088      });
27089      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
27090    })();
27091  </script>
27092  <script nonce="{{ csp_nonce }}">
27093  (function(){
27094    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'}];
27095    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);});}
27096    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27097    function init(){
27098      var btn=document.getElementById('settings-btn');if(!btn)return;
27099      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27100      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>';
27101      document.body.appendChild(m);
27102      var g=document.getElementById('scheme-grid');
27103      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);});
27104      var cl=document.getElementById('settings-close');
27105      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);
27106      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');});
27107      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27108      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27109    }
27110    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27111  }());
27112  </script>
27113  <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]';
27114  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;}
27115  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>
27116</body>
27117</html>
27118"##,
27119    ext = "html"
27120)]
27121// Template structs need many bool fields to pass Askama rendering flags.
27122#[allow(clippy::struct_excessive_bools)]
27123struct CompareTemplate {
27124    version: &'static str,
27125    project_label: String,
27126    baseline_git_commit: String,
27127    current_git_commit: String,
27128    baseline_run_id: String,
27129    current_run_id: String,
27130    baseline_run_id_short: String,
27131    current_run_id_short: String,
27132    baseline_timestamp: String,
27133    baseline_timestamp_utc_ms: i64,
27134    current_timestamp: String,
27135    current_timestamp_utc_ms: i64,
27136    project_path: String,
27137    baseline_code: u64,
27138    current_code: u64,
27139    code_lines_delta_str: String,
27140    code_lines_delta_class: String,
27141    baseline_files: u64,
27142    current_files: u64,
27143    files_analyzed_delta_str: String,
27144    files_analyzed_delta_class: String,
27145    baseline_comments: u64,
27146    current_comments: u64,
27147    comment_lines_delta_str: String,
27148    comment_lines_delta_class: String,
27149    baseline_code_fmt: String,
27150    current_code_fmt: String,
27151    baseline_files_fmt: String,
27152    current_files_fmt: String,
27153    baseline_comments_fmt: String,
27154    current_comments_fmt: String,
27155    code_lines_pct_str: String,
27156    files_analyzed_pct_str: String,
27157    comment_lines_pct_str: String,
27158    code_lines_added: i64,
27159    code_lines_removed: i64,
27160    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
27161    new_scope: bool,
27162    churn_rate_str: String,
27163    churn_rate_class: String,
27164    scope_flag: bool,
27165    files_added: usize,
27166    files_removed: usize,
27167    files_modified: usize,
27168    files_unchanged: usize,
27169    file_rows: Vec<CompareFileDeltaRow>,
27170    baseline_git_author: Option<String>,
27171    current_git_author: Option<String>,
27172    baseline_git_branch: String,
27173    current_git_branch: String,
27174    baseline_git_tags: Option<String>,
27175    current_git_tags: Option<String>,
27176    baseline_git_commit_date: Option<String>,
27177    current_git_commit_date: Option<String>,
27178    project_name: String,
27179    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
27180    submodule_options: Vec<String>,
27181    /// True when either run has submodule data — controls whether the scope bar is shown.
27182    has_any_submodule_data: bool,
27183    /// The submodule currently being compared, if the `sub` query param was provided.
27184    active_submodule: Option<String>,
27185    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
27186    super_scope_active: bool,
27187    csp_nonce: String,
27188    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
27189    coverage_delta_card: String,
27190    baseline_test_count: u64,
27191    current_test_count: u64,
27192    baseline_coverage_pct: Option<f64>,
27193    current_coverage_pct: Option<f64>,
27194}
27195
27196// ── LoginTemplate ──────────────────────────────────────────────────────────────
27197
27198#[derive(Template)]
27199#[template(
27200    source = r##"
27201<!doctype html>
27202<html lang="en">
27203<head>
27204  <meta charset="utf-8">
27205  <meta name="viewport" content="width=device-width, initial-scale=1">
27206  <title>OxideSLOC | Sign In</title>
27207  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27208  <style nonce="{{ csp_nonce }}">
27209    :root {
27210      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
27211      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
27212      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
27213      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
27214    }
27215    *{box-sizing:border-box;}
27216    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);}
27217    .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);}
27218    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
27219    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
27220    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
27221    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27222    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27223    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27224    .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;}
27225    @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));}}
27226    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
27227    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
27228    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27229    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
27230    .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;}
27231    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
27232    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;}
27233    input[type=password]:focus{border-color:var(--oxide);}
27234    .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;}
27235    .btn:hover{opacity:.88;}
27236    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
27237    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
27238  </style>
27239</head>
27240<body>
27241  <div class="background-watermarks" aria-hidden="true">
27242    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27243    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27244    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27245    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27246    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27247    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27248    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27249  </div>
27250  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27251<nav class="top-nav">
27252  <a class="brand" href="/">
27253    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
27254    <span class="brand-title">OxideSLOC</span>
27255  </a>
27256</nav>
27257<main class="page">
27258  <div class="card">
27259    <h1>Sign In</h1>
27260    <p class="subtitle">Enter the API key printed when the server started.</p>
27261    {% if has_error %}
27262    <div class="error">Incorrect API key — please try again.</div>
27263    {% endif %}
27264    <form method="POST" action="/auth/login">
27265      <input type="hidden" name="next" value="{{ next_url|e }}">
27266      <label for="key">API Key</label>
27267      <input id="key" type="password" name="key" autocomplete="current-password"
27268             placeholder="Paste your API key here" autofocus>
27269      <button type="submit" class="btn">Sign In</button>
27270    </form>
27271    <p class="hint">
27272      The API key was printed in the terminal when the server started.<br>
27273      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
27274      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
27275    </p>
27276  </div>
27277</main>
27278<script nonce="{{ csp_nonce }}">
27279(function() {
27280  (function randomizeWatermarks() {
27281    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27282    if (!wms.length) return;
27283    var placed = [];
27284    function tooClose(top, left) {
27285      for (var i = 0; i < placed.length; i++) {
27286        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
27287        if (dt < 16 && dl < 12) return true;
27288      }
27289      return false;
27290    }
27291    function pick(leftBand) {
27292      for (var attempt = 0; attempt < 50; attempt++) {
27293        var top = Math.random() * 88 + 2;
27294        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27295        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
27296      }
27297      var top = Math.random() * 88 + 2;
27298      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27299      placed.push([top, left]); return [top, left];
27300    }
27301    var half = Math.floor(wms.length / 2);
27302    wms.forEach(function (img, i) {
27303      var pos = pick(i < half);
27304      var size = Math.floor(Math.random() * 100 + 120);
27305      var rot = (Math.random() * 360).toFixed(1);
27306      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
27307      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;
27308    });
27309  })();
27310  (function spawnCodeParticles() {
27311    var container = document.getElementById('code-particles');
27312    if (!container) return;
27313    var snippets = [
27314      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
27315      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
27316      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
27317      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
27318      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
27319    ];
27320    var count = 38;
27321    for (var i = 0; i < count; i++) {
27322      (function(idx) {
27323        var el = document.createElement('span');
27324        el.className = 'code-particle';
27325        el.textContent = snippets[idx % snippets.length];
27326        var left = Math.random() * 94 + 2;
27327        var top = Math.random() * 88 + 6;
27328        var dur = (Math.random() * 10 + 9).toFixed(1);
27329        var delay = (Math.random() * 18).toFixed(1);
27330        var rot = (Math.random() * 26 - 13).toFixed(1);
27331        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27332        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
27333        container.appendChild(el);
27334      })(i);
27335    }
27336  })();
27337})();
27338</script>
27339</body>
27340</html>
27341"##,
27342    ext = "html"
27343)]
27344pub(crate) struct LoginTemplate {
27345    pub(crate) csp_nonce: String,
27346    pub(crate) has_error: bool,
27347    pub(crate) next_url: String,
27348    pub(crate) lockout_threshold: u32,
27349}
27350
27351// ── REST API reference page ────────────────────────────────────────────────────
27352
27353#[derive(Template)]
27354#[template(
27355    source = r##"
27356<!doctype html>
27357<html lang="en">
27358<head>
27359  <meta charset="utf-8">
27360  <meta name="viewport" content="width=device-width, initial-scale=1">
27361  <title>OxideSLOC — REST API Reference</title>
27362  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27363  <style nonce="{{ csp_nonce }}">
27364    :root {
27365      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27366      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27367      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27368      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27369      --success:#16a34a;
27370    }
27371    body.dark-theme {
27372      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
27373      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
27374    }
27375    *{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;}
27376    .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);}
27377    .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;}
27378    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
27379    .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));}
27380    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
27381    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
27382    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
27383    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
27384    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27385    @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; } }
27386    .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;}
27387    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
27388    .nav-pill.active{background:rgba(255,255,255,0.22);}
27389    .nav-dropdown{position:relative;display:inline-flex;}
27390    .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;}
27391    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
27392    .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;}
27393    .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;}
27394    .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);}
27395    .nav-dropdown-menu a:last-child{border-bottom:none;}
27396    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
27397    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
27398    .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;}
27399    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27400    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27401    .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;}
27402    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27403    .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);}
27404    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
27405    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
27406    .settings-modal-body{padding:14px 16px 16px;}
27407    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27408    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27409    .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;}
27410    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27411    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27412    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27413    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27414    .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;}
27415    .tz-select:focus{border-color:var(--oxide);}
27416    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
27417    .page-header{margin-bottom:28px;}
27418    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
27419    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
27420    .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;}
27421    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
27422    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
27423    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
27424    .callout strong{font-weight:800;}
27425    .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;}
27426    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
27427    .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;}
27428    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
27429    .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;}
27430    body.dark-theme .base-url-value{color:var(--accent);}
27431    .section{margin-bottom:36px;}
27432    .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);}
27433    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
27434    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
27435    .ep-header:hover{background:var(--surface-2);}
27436    .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;}
27437    .method.get{background:#dcfce7;color:#166534;}
27438    .method.post{background:#dbeafe;color:#1e40af;}
27439    .method.delete{background:#fee2e2;color:#991b1b;}
27440    body.dark-theme .method.get{background:#14532d;color:#86efac;}
27441    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
27442    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
27443    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
27444    .ep-path .param{color:var(--oxide-2);}
27445    body.dark-theme .ep-path .param{color:var(--oxide);}
27446    .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;}
27447    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
27448    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
27449    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
27450    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
27451    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
27452    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
27453    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
27454    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
27455    .ep-card.open .chevron{transform:rotate(180deg);}
27456    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
27457    .ep-card.open .ep-body{display:block;}
27458    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
27459    .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;}
27460    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
27461    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
27462    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27463    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
27464    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);}
27465    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
27466    table.params tr:last-child td{border-bottom:none;}
27467    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
27468    .pt-type{color:var(--muted-2);font-size:12px;}
27469    .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;}
27470    .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;}
27471    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
27472    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
27473    details.schema{margin-bottom:14px;}
27474    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;}
27475    details.schema summary:hover{color:var(--text);}
27476    .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;}
27477    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27478    .curl-wrap{position:relative;}
27479    .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;}
27480    .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;}
27481    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
27482    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
27483    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
27484    .webhook-note a{color:var(--accent-2);text-decoration:none;}
27485    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27486    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27487    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27488    .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;}
27489    @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));}}
27490    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27491    .site-footer a{color:var(--muted);}
27492  </style>
27493</head>
27494<body>
27495  <div class="background-watermarks" aria-hidden="true">
27496    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27497    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27498    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27499    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27500    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27501    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27502    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27503  </div>
27504  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27505  <div class="top-nav">
27506    <div class="top-nav-inner">
27507      <a class="brand" href="/">
27508        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27509        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
27510      </a>
27511      <div class="nav-right">
27512        <a class="nav-pill" href="/">Home</a>
27513        <div class="nav-dropdown">
27514          <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>
27515          <div class="nav-dropdown-menu">
27516            <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>
27517          </div>
27518        </div>
27519        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27520        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27521        <div class="nav-dropdown">
27522          <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>
27523          <div class="nav-dropdown-menu">
27524            <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>
27525          </div>
27526        </div>
27527        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27528          <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>
27529        </button>
27530        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27531          <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>
27532          <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>
27533        </button>
27534      </div>
27535    </div>
27536  </div>
27537
27538  <div class="page">
27539    <div class="page-header">
27540      <h1 class="page-title">REST API Reference</h1>
27541      <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>
27542    </div>
27543
27544    {% if has_api_key %}
27545    <div class="callout key-set">
27546      <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>
27547      <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>
27548    </div>
27549    {% else %}
27550    <div class="callout no-key">
27551      <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>
27552      <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>
27553    </div>
27554    {% endif %}
27555
27556    <div class="base-url-bar">
27557      <span class="base-url-label">Base URL</span>
27558      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
27559    </div>
27560
27561    <!-- Health -->
27562    <div class="section">
27563      <h2 class="section-title">Health &amp; Status</h2>
27564      <div class="ep-card">
27565        <div class="ep-header">
27566          <span class="method get">GET</span>
27567          <span class="ep-path">/healthz</span>
27568          <span class="auth-badge public">Public</span>
27569          <span class="ep-desc">Server liveness check</span>
27570          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27571        </div>
27572        <div class="ep-body">
27573          <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>
27574          <p class="params-heading">Response</p>
27575          <div class="schema-block">200 OK
27576Content-Type: text/plain
27577
27578ok</div>
27579          <p class="curl-heading">Example</p>
27580          <div class="curl-wrap">
27581            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
27582            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
27583          </div>
27584        </div>
27585      </div>
27586    </div>
27587
27588    <!-- Badges -->
27589    <div class="section">
27590      <h2 class="section-title">Badges</h2>
27591      <div class="ep-card">
27592        <div class="ep-header">
27593          <span class="method get">GET</span>
27594          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
27595          <span class="auth-badge public">Public</span>
27596          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
27597          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27598        </div>
27599        <div class="ep-body">
27600          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
27601          <p class="params-heading">Path Parameters</p>
27602          <table class="params">
27603            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27604            <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>
27605          </table>
27606          <p class="curl-heading">Example</p>
27607          <div class="curl-wrap">
27608            <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>
27609            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
27610          </div>
27611        </div>
27612      </div>
27613    </div>
27614
27615    <!-- Metrics -->
27616    <div class="section">
27617      <h2 class="section-title">Metrics</h2>
27618
27619      <div class="ep-card">
27620        <div class="ep-header">
27621          <span class="method get">GET</span>
27622          <span class="ep-path">/api/metrics/latest</span>
27623          <span class="auth-badge protected">Protected</span>
27624          <span class="ep-desc">Latest scan metrics (JSON)</span>
27625          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27626        </div>
27627        <div class="ep-body">
27628          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
27629          <details class="schema"><summary>Response schema</summary>
27630<div class="schema-block">{
27631  "run_id":    string,        // UUID
27632  "timestamp": string,        // ISO-8601 UTC
27633  "project":   string,        // scanned root path
27634  "summary": {
27635    "files_analyzed":       number,
27636    "files_skipped":        number,
27637    "code_lines":           number,
27638    "comment_lines":        number,
27639    "blank_lines":          number,
27640    "total_physical_lines": number,
27641    "functions":            number,
27642    "classes":              number,
27643    "variables":            number,
27644    "imports":              number
27645  },
27646  "languages": [
27647    { "name": string, "files": number, "code_lines": number,
27648      "comment_lines": number, "blank_lines": number,
27649      "functions": number, "classes": number,
27650      "variables": number, "imports": number }
27651  ]
27652}</div></details>
27653          <p class="curl-heading">Example</p>
27654          <div class="curl-wrap">
27655            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27656  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
27657            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
27658          </div>
27659        </div>
27660      </div>
27661
27662      <div class="ep-card">
27663        <div class="ep-header">
27664          <span class="method get">GET</span>
27665          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
27666          <span class="auth-badge protected">Protected</span>
27667          <span class="ep-desc">Metrics for a specific run</span>
27668          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27669        </div>
27670        <div class="ep-body">
27671          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
27672          <p class="params-heading">Path Parameters</p>
27673          <table class="params">
27674            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27675            <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>
27676          </table>
27677          <p class="curl-heading">Example</p>
27678          <div class="curl-wrap">
27679            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27680  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
27681            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
27682          </div>
27683        </div>
27684      </div>
27685
27686      <div class="ep-card">
27687        <div class="ep-header">
27688          <span class="method get">GET</span>
27689          <span class="ep-path">/api/metrics/history</span>
27690          <span class="auth-badge protected">Protected</span>
27691          <span class="ep-desc">Paginated scan history</span>
27692          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27693        </div>
27694        <div class="ep-body">
27695          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
27696          <p class="params-heading">Query Parameters</p>
27697          <table class="params">
27698            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27699            <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>
27700            <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>
27701          </table>
27702          <details class="schema"><summary>Response schema</summary>
27703<div class="schema-block">[{
27704  "run_id":         string,
27705  "timestamp":      string,   // ISO-8601 UTC
27706  "commit":         string | null,
27707  "branch":         string | null,
27708  "tags":           string[],
27709  "code_lines":     number,
27710  "comment_lines":  number,
27711  "blank_lines":    number,
27712  "physical_lines": number,
27713  "files_analyzed": number,
27714  "project_label":  string,
27715  "html_url":       string | null
27716}]</div></details>
27717          <p class="curl-heading">Example</p>
27718          <div class="curl-wrap">
27719            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27720  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
27721            <button class="curl-copy-btn" data-target="c-metrics-history">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/project-history</span>
27730          <span class="auth-badge protected">Protected</span>
27731          <span class="ep-desc">Project-level scan summary</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 a high-level project summary: total scans, last scan ID and timestamp, last code-line count, and most recent git metadata.</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">path</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by root path</td></tr>
27740          </table>
27741          <details class="schema"><summary>Response schema</summary>
27742<div class="schema-block">{
27743  "scan_count":           number,
27744  "last_scan_id":         string | null,
27745  "last_scan_timestamp":  string | null,  // ISO-8601
27746  "last_scan_code_lines": number | null,
27747  "last_git_branch":      string | null,
27748  "last_git_commit":      string | null
27749}</div></details>
27750          <p class="curl-heading">Example</p>
27751          <div class="curl-wrap">
27752            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27753  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
27754            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
27755          </div>
27756        </div>
27757      </div>
27758
27759      <div class="ep-card">
27760        <div class="ep-header">
27761          <span class="method get">GET</span>
27762          <span class="ep-path">/api/metrics/submodules</span>
27763          <span class="auth-badge protected">Protected</span>
27764          <span class="ep-desc">List known git submodules across scans</span>
27765          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27766        </div>
27767        <div class="ep-body">
27768          <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>
27769          <p class="params-heading">Query Parameters</p>
27770          <table class="params">
27771            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27772            <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>
27773          </table>
27774          <details class="schema"><summary>Response schema</summary>
27775<div class="schema-block">[{
27776  "name":          string,  // submodule name
27777  "relative_path": string   // path relative to the project root
27778}]</div></details>
27779          <p class="curl-heading">Example</p>
27780          <div class="curl-wrap">
27781            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27782  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
27783            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
27784          </div>
27785        </div>
27786      </div>
27787    </div>
27788
27789    <!-- Async Run Status -->
27790    <div class="section">
27791      <h2 class="section-title">Async Run Status</h2>
27792
27793      <div class="ep-card">
27794        <div class="ep-header">
27795          <span class="method get">GET</span>
27796          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
27797          <span class="auth-badge protected">Protected</span>
27798          <span class="ep-desc">Poll scan completion</span>
27799          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27800        </div>
27801        <div class="ep-body">
27802          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
27803          <details class="schema"><summary>Response schema</summary>
27804<div class="schema-block">// Running
27805{ "state": "running",  "elapsed_secs": number }
27806
27807// Complete
27808{ "state": "complete", "run_id": string }
27809
27810// Failed
27811{ "state": "failed",   "message": string }</div></details>
27812          <p class="curl-heading">Example</p>
27813          <div class="curl-wrap">
27814            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27815  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
27816            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
27817          </div>
27818        </div>
27819      </div>
27820
27821      <div class="ep-card">
27822        <div class="ep-header">
27823          <span class="method get">GET</span>
27824          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
27825          <span class="auth-badge protected">Protected</span>
27826          <span class="ep-desc">Poll PDF generation readiness</span>
27827          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27828        </div>
27829        <div class="ep-body">
27830          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
27831          <details class="schema"><summary>Response schema</summary>
27832<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
27833          <p class="curl-heading">Example</p>
27834          <div class="curl-wrap">
27835            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27836  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
27837            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
27838          </div>
27839        </div>
27840      </div>
27841
27842      <div class="ep-card">
27843        <div class="ep-header">
27844          <span class="method post">POST</span>
27845          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
27846          <span class="auth-badge protected">Protected</span>
27847          <span class="ep-desc">Cancel a running scan</span>
27848          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27849        </div>
27850        <div class="ep-body">
27851          <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>
27852          <p class="curl-heading">Example</p>
27853          <div class="curl-wrap">
27854            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
27855  -H "Authorization: Bearer $SLOC_API_KEY" \
27856  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
27857            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
27858          </div>
27859        </div>
27860      </div>
27861    </div>
27862
27863    <!-- Run Management -->
27864    <div class="section">
27865      <h2 class="section-title">Run Management</h2>
27866
27867      <div class="ep-card">
27868        <div class="ep-header">
27869          <span class="method get">GET</span>
27870          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
27871          <span class="auth-badge protected">Protected</span>
27872          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
27873          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27874        </div>
27875        <div class="ep-body">
27876          <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>
27877          <p class="params-heading">Path Parameters</p>
27878          <table class="params">
27879            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27880            <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>
27881          </table>
27882          <details class="schema"><summary>Response</summary>
27883<div class="schema-block">200 OK — Content-Type: application/zip
27884Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
27885
27886404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
27887          <p class="curl-heading">Example</p>
27888          <div class="curl-wrap">
27889            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27890  -o run.zip \
27891  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
27892            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
27893          </div>
27894        </div>
27895      </div>
27896
27897      <div class="ep-card">
27898        <div class="ep-header">
27899          <span class="method delete">DELETE</span>
27900          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
27901          <span class="auth-badge protected">Protected</span>
27902          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
27903          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27904        </div>
27905        <div class="ep-body">
27906          <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>
27907          <p class="params-heading">Path Parameters</p>
27908          <table class="params">
27909            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27910            <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>
27911          </table>
27912          <details class="schema"><summary>Response</summary>
27913<div class="schema-block">204 No Content — run successfully deleted
27914
27915500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
27916          <p class="curl-heading">Example</p>
27917          <div class="curl-wrap">
27918            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
27919  -H "Authorization: Bearer $SLOC_API_KEY" \
27920  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
27921            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
27922          </div>
27923        </div>
27924      </div>
27925
27926      <div class="ep-card">
27927        <div class="ep-header">
27928          <span class="method post">POST</span>
27929          <span class="ep-path">/api/runs/cleanup</span>
27930          <span class="auth-badge protected">Protected</span>
27931          <span class="ep-desc">Bulk delete runs older than N days</span>
27932          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27933        </div>
27934        <div class="ep-body">
27935          <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>
27936          <p class="params-heading">Request Body (application/json)</p>
27937          <table class="params">
27938            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
27939            <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>
27940          </table>
27941          <details class="schema"><summary>Response schema</summary>
27942<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
27943          <p class="curl-heading">Example — delete runs older than 60 days</p>
27944          <div class="curl-wrap">
27945            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
27946  -H "Authorization: Bearer $SLOC_API_KEY" \
27947  -H "Content-Type: application/json" \
27948  -d '{"older_than_days":60}' \
27949  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
27950            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
27951          </div>
27952        </div>
27953      </div>
27954    </div>
27955
27956    <!-- Retention Policy -->
27957    <div class="section">
27958      <h2 class="section-title">Retention Policy</h2>
27959
27960      <div class="ep-card">
27961        <div class="ep-header">
27962          <span class="method get">GET</span>
27963          <span class="ep-path">/api/cleanup-policy</span>
27964          <span class="auth-badge protected">Protected</span>
27965          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
27966          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27967        </div>
27968        <div class="ep-body">
27969          <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>
27970          <details class="schema"><summary>Response schema</summary>
27971<div class="schema-block">{
27972  "policy": {
27973    "enabled":       boolean,
27974    "max_age_days":  number | null,   // delete runs older than N days
27975    "max_run_count": number | null,   // keep only the N most recent runs
27976    "interval_hours": number          // hours between background passes
27977  } | null,
27978  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
27979  "last_run_deleted": number | null   // runs deleted in last pass
27980}</div></details>
27981          <p class="curl-heading">Example</p>
27982          <div class="curl-wrap">
27983            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27984  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
27985            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
27986          </div>
27987        </div>
27988      </div>
27989
27990      <div class="ep-card">
27991        <div class="ep-header">
27992          <span class="method post">POST</span>
27993          <span class="ep-path">/api/cleanup-policy</span>
27994          <span class="auth-badge protected">Protected</span>
27995          <span class="ep-desc">Save or update the retention policy</span>
27996          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27997        </div>
27998        <div class="ep-body">
27999          <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>
28000          <p class="params-heading">Request Body (application/json)</p>
28001          <table class="params">
28002            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28003            <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>
28004            <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>
28005            <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>
28006            <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>
28007          </table>
28008          <details class="schema"><summary>Response</summary>
28009<div class="schema-block">204 No Content — policy saved and task (re)started
28010
28011500 Internal Server Error — { "error": string }</div></details>
28012          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
28013          <div class="curl-wrap">
28014            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
28015  -H "Authorization: Bearer $SLOC_API_KEY" \
28016  -H "Content-Type: application/json" \
28017  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
28018  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28019            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
28020          </div>
28021        </div>
28022      </div>
28023
28024      <div class="ep-card">
28025        <div class="ep-header">
28026          <span class="method post">POST</span>
28027          <span class="ep-path">/api/cleanup-policy/run-now</span>
28028          <span class="auth-badge protected">Protected</span>
28029          <span class="ep-desc">Trigger an immediate cleanup pass</span>
28030          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28031        </div>
28032        <div class="ep-body">
28033          <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>
28034          <details class="schema"><summary>Response schema</summary>
28035<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
28036          <p class="curl-heading">Example</p>
28037          <div class="curl-wrap">
28038            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
28039  -H "Authorization: Bearer $SLOC_API_KEY" \
28040  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
28041            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
28042          </div>
28043        </div>
28044      </div>
28045
28046      <div class="ep-card">
28047        <div class="ep-header">
28048          <span class="method delete">DELETE</span>
28049          <span class="ep-path">/api/cleanup-policy</span>
28050          <span class="auth-badge protected">Protected</span>
28051          <span class="ep-desc">Remove the retention policy and stop the background task</span>
28052          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28053        </div>
28054        <div class="ep-body">
28055          <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>
28056          <details class="schema"><summary>Response</summary>
28057<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
28058          <p class="curl-heading">Example</p>
28059          <div class="curl-wrap">
28060            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
28061  -H "Authorization: Bearer $SLOC_API_KEY" \
28062  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28063            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
28064          </div>
28065        </div>
28066      </div>
28067    </div>
28068
28069    <!-- Scan Profiles -->
28070    <div class="section">
28071      <h2 class="section-title">Scan Profiles</h2>
28072
28073      <div class="ep-card">
28074        <div class="ep-header">
28075          <span class="method get">GET</span>
28076          <span class="ep-path">/api/scan-profiles</span>
28077          <span class="auth-badge protected">Protected</span>
28078          <span class="ep-desc">List saved scan profiles</span>
28079          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28080        </div>
28081        <div class="ep-body">
28082          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
28083          <details class="schema"><summary>Response schema</summary>
28084<div class="schema-block">{
28085  "profiles": [{
28086    "id":         string,   // UUID
28087    "name":       string,
28088    "created_at": string,   // ISO-8601
28089    "params":     object
28090  }]
28091}</div></details>
28092          <p class="curl-heading">Example</p>
28093          <div class="curl-wrap">
28094            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28095  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28096            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
28097          </div>
28098        </div>
28099      </div>
28100
28101      <div class="ep-card">
28102        <div class="ep-header">
28103          <span class="method post">POST</span>
28104          <span class="ep-path">/api/scan-profiles</span>
28105          <span class="auth-badge protected">Protected</span>
28106          <span class="ep-desc">Save a scan profile</span>
28107          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28108        </div>
28109        <div class="ep-body">
28110          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
28111          <p class="params-heading">Request Body (application/json)</p>
28112          <table class="params">
28113            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28114            <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>
28115            <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>
28116          </table>
28117          <details class="schema"><summary>Response schema</summary>
28118<div class="schema-block">{ "ok": true }</div></details>
28119          <p class="curl-heading">Example</p>
28120          <div class="curl-wrap">
28121            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
28122  -H "Authorization: Bearer $SLOC_API_KEY" \
28123  -H "Content-Type: application/json" \
28124  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
28125  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28126            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
28127          </div>
28128        </div>
28129      </div>
28130
28131      <div class="ep-card">
28132        <div class="ep-header">
28133          <span class="method delete">DELETE</span>
28134          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
28135          <span class="auth-badge protected">Protected</span>
28136          <span class="ep-desc">Delete a scan profile</span>
28137          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28138        </div>
28139        <div class="ep-body">
28140          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
28141          <p class="params-heading">Path Parameters</p>
28142          <table class="params">
28143            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28144            <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>
28145          </table>
28146          <details class="schema"><summary>Response schema</summary>
28147<div class="schema-block">{ "ok": true }</div></details>
28148          <p class="curl-heading">Example</p>
28149          <div class="curl-wrap">
28150            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
28151  -H "Authorization: Bearer $SLOC_API_KEY" \
28152  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
28153            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
28154          </div>
28155        </div>
28156      </div>
28157    </div>
28158
28159    <!-- Scheduled Scans -->
28160    <div class="section">
28161      <h2 class="section-title">Scheduled Scans</h2>
28162
28163      <div class="ep-card">
28164        <div class="ep-header">
28165          <span class="method get">GET</span>
28166          <span class="ep-path">/api/schedules</span>
28167          <span class="auth-badge protected">Protected</span>
28168          <span class="ep-desc">List configured schedules</span>
28169          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28170        </div>
28171        <div class="ep-body">
28172          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
28173          <p class="curl-heading">Example</p>
28174          <div class="curl-wrap">
28175            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28176  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28177            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
28178          </div>
28179        </div>
28180      </div>
28181
28182      <div class="ep-card">
28183        <div class="ep-header">
28184          <span class="method post">POST</span>
28185          <span class="ep-path">/api/schedules</span>
28186          <span class="auth-badge protected">Protected</span>
28187          <span class="ep-desc">Create a schedule</span>
28188          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28189        </div>
28190        <div class="ep-body">
28191          <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>
28192          <p class="curl-heading">Example</p>
28193          <div class="curl-wrap">
28194            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
28195  -H "Authorization: Bearer $SLOC_API_KEY" \
28196  -H "Content-Type: application/json" \
28197  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
28198  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28199            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
28200          </div>
28201        </div>
28202      </div>
28203
28204      <div class="ep-card">
28205        <div class="ep-header">
28206          <span class="method delete">DELETE</span>
28207          <span class="ep-path">/api/schedules</span>
28208          <span class="auth-badge protected">Protected</span>
28209          <span class="ep-desc">Delete a schedule</span>
28210          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28211        </div>
28212        <div class="ep-body">
28213          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
28214          <p class="curl-heading">Example</p>
28215          <div class="curl-wrap">
28216            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
28217  -H "Authorization: Bearer $SLOC_API_KEY" \
28218  -H "Content-Type: application/json" \
28219  -d '{"id":"&lt;schedule_id&gt;"}' \
28220  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28221            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
28222          </div>
28223        </div>
28224      </div>
28225    </div>
28226
28227    <!-- Git Browser -->
28228    <div class="section">
28229      <h2 class="section-title">Git Browser</h2>
28230
28231      <div class="ep-card">
28232        <div class="ep-header">
28233          <span class="method get">GET</span>
28234          <span class="ep-path">/api/git/refs</span>
28235          <span class="auth-badge protected">Protected</span>
28236          <span class="ep-desc">List git refs for a repository</span>
28237          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28238        </div>
28239        <div class="ep-body">
28240          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
28241          <p class="params-heading">Query Parameters</p>
28242          <table class="params">
28243            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28244            <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>
28245          </table>
28246          <p class="curl-heading">Example</p>
28247          <div class="curl-wrap">
28248            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28249  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
28250            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
28251          </div>
28252        </div>
28253      </div>
28254
28255      <div class="ep-card">
28256        <div class="ep-header">
28257          <span class="method get">GET</span>
28258          <span class="ep-path">/api/git/scan-ref</span>
28259          <span class="auth-badge protected">Protected</span>
28260          <span class="ep-desc">SLOC-scan a specific git ref</span>
28261          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28262        </div>
28263        <div class="ep-body">
28264          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
28265          <p class="params-heading">Query Parameters</p>
28266          <table class="params">
28267            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28268            <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>
28269            <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>
28270          </table>
28271          <p class="curl-heading">Example</p>
28272          <div class="curl-wrap">
28273            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28274  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
28275            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
28276          </div>
28277        </div>
28278      </div>
28279
28280      <div class="ep-card">
28281        <div class="ep-header">
28282          <span class="method get">GET</span>
28283          <span class="ep-path">/api/git/compare-refs</span>
28284          <span class="auth-badge protected">Protected</span>
28285          <span class="ep-desc">Compare SLOC across two git refs</span>
28286          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28287        </div>
28288        <div class="ep-body">
28289          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
28290          <p class="params-heading">Query Parameters</p>
28291          <table class="params">
28292            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28293            <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>
28294            <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>
28295            <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>
28296          </table>
28297          <p class="curl-heading">Example</p>
28298          <div class="curl-wrap">
28299            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28300  "<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>
28301            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
28302          </div>
28303        </div>
28304      </div>
28305    </div>
28306
28307    <!-- Webhooks -->
28308    <div class="section">
28309      <h2 class="section-title">Webhooks</h2>
28310      <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>
28311
28312      <div class="ep-card">
28313        <div class="ep-header">
28314          <span class="method post">POST</span>
28315          <span class="ep-path">/webhooks/github</span>
28316          <span class="auth-badge hmac">HMAC</span>
28317          <span class="ep-desc">GitHub push event receiver</span>
28318          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28319        </div>
28320        <div class="ep-body">
28321          <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>
28322          <p class="params-heading">Required Headers</p>
28323          <table class="params">
28324            <tr><th>Header</th><th>Value</th></tr>
28325            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
28326            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
28327            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28328          </table>
28329        </div>
28330      </div>
28331
28332      <div class="ep-card">
28333        <div class="ep-header">
28334          <span class="method post">POST</span>
28335          <span class="ep-path">/webhooks/gitlab</span>
28336          <span class="auth-badge hmac">HMAC</span>
28337          <span class="ep-desc">GitLab push event receiver</span>
28338          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28339        </div>
28340        <div class="ep-body">
28341          <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>
28342          <p class="params-heading">Required Headers</p>
28343          <table class="params">
28344            <tr><th>Header</th><th>Value</th></tr>
28345            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
28346            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
28347            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28348          </table>
28349        </div>
28350      </div>
28351
28352      <div class="ep-card">
28353        <div class="ep-header">
28354          <span class="method post">POST</span>
28355          <span class="ep-path">/webhooks/bitbucket</span>
28356          <span class="auth-badge hmac">HMAC</span>
28357          <span class="ep-desc">Bitbucket 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 Bitbucket push events. Authenticated via <code>X-Hub-Signature</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</td><td>HMAC-SHA256 of the raw body</td></tr>
28366            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28367          </table>
28368        </div>
28369      </div>
28370    </div>
28371
28372    <!-- Config -->
28373    <div class="section">
28374      <h2 class="section-title">Config Import / Export</h2>
28375
28376      <div class="ep-card">
28377        <div class="ep-header">
28378          <span class="method get">GET</span>
28379          <span class="ep-path">/export-config</span>
28380          <span class="auth-badge protected">Protected</span>
28381          <span class="ep-desc">Export server configuration as JSON</span>
28382          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28383        </div>
28384        <div class="ep-body">
28385          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
28386          <p class="curl-heading">Example</p>
28387          <div class="curl-wrap">
28388            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28389  -o config.json \
28390  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
28391            <button class="curl-copy-btn" data-target="c-export">Copy</button>
28392          </div>
28393        </div>
28394      </div>
28395
28396      <div class="ep-card">
28397        <div class="ep-header">
28398          <span class="method post">POST</span>
28399          <span class="ep-path">/import-config</span>
28400          <span class="auth-badge protected">Protected</span>
28401          <span class="ep-desc">Import server configuration</span>
28402          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28403        </div>
28404        <div class="ep-body">
28405          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
28406          <p class="curl-heading">Example</p>
28407          <div class="curl-wrap">
28408            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
28409  -H "Authorization: Bearer $SLOC_API_KEY" \
28410  -H "Content-Type: application/json" \
28411  -d @config.json \
28412  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
28413            <button class="curl-copy-btn" data-target="c-import">Copy</button>
28414          </div>
28415        </div>
28416      </div>
28417    </div>
28418
28419    <!-- CI Ingest -->
28420    <div class="section">
28421      <h2 class="section-title">CI Ingest</h2>
28422
28423      <div class="ep-card">
28424        <div class="ep-header">
28425          <span class="method post">POST</span>
28426          <span class="ep-path">/api/ingest</span>
28427          <span class="auth-badge protected">Protected</span>
28428          <span class="ep-desc">Push a pre-computed scan result from CI</span>
28429          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28430        </div>
28431        <div class="ep-body">
28432          <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>
28433          <p class="params-heading">Query Parameters</p>
28434          <table class="params">
28435            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28436            <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>
28437          </table>
28438          <p class="params-heading">Request Body (application/json)</p>
28439          <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>
28440          <details class="schema"><summary>Response schema</summary>
28441<div class="schema-block">// 201 Created
28442{
28443  "run_id":   string,  // UUID of the ingested run
28444  "view_url": string   // relative URL to the report page
28445}</div></details>
28446          <p class="curl-heading">Example</p>
28447          <div class="curl-wrap">
28448            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
28449  -H "Authorization: Bearer $SLOC_API_KEY" \
28450  -H "Content-Type: application/json" \
28451  -d @result.json \
28452  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
28453            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
28454          </div>
28455        </div>
28456      </div>
28457    </div>
28458
28459    <!-- Artifact Download -->
28460    <div class="section">
28461      <h2 class="section-title">Artifact Download</h2>
28462
28463      <div class="ep-card">
28464        <div class="ep-header">
28465          <span class="method get">GET</span>
28466          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
28467          <span class="auth-badge protected">Protected</span>
28468          <span class="ep-desc">Download or view a scan artifact</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">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
28473          <p class="params-heading">Path 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">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>
28477            <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>
28478          </table>
28479          <p class="params-heading">Query Parameters</p>
28480          <table class="params">
28481            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28482            <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>
28483          </table>
28484          <p class="curl-heading">Example — download JSON result</p>
28485          <div class="curl-wrap">
28486            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28487  -o result.json \
28488  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
28489            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
28490          </div>
28491        </div>
28492      </div>
28493    </div>
28494
28495    <!-- Embed Widget -->
28496    <div class="section">
28497      <h2 class="section-title">Embed Widget</h2>
28498
28499      <div class="ep-card">
28500        <div class="ep-header">
28501          <span class="method get">GET</span>
28502          <span class="ep-path">/embed/summary</span>
28503          <span class="auth-badge protected">Protected</span>
28504          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
28505          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28506        </div>
28507        <div class="ep-body">
28508          <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>
28509          <p class="params-heading">Query Parameters</p>
28510          <table class="params">
28511            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28512            <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>
28513            <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>
28514          </table>
28515          <p class="curl-heading">Example</p>
28516          <div class="curl-wrap">
28517            <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"
28518        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
28519            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
28520          </div>
28521        </div>
28522      </div>
28523    </div>
28524
28525    <!-- Confluence Integration -->
28526    <div class="section">
28527      <h2 class="section-title">Confluence Integration</h2>
28528
28529      <div class="ep-card">
28530        <div class="ep-header">
28531          <span class="method get">GET</span>
28532          <span class="ep-path">/api/confluence/config</span>
28533          <span class="auth-badge protected">Protected</span>
28534          <span class="ep-desc">Get current Confluence configuration</span>
28535          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28536        </div>
28537        <div class="ep-body">
28538          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
28539          <details class="schema"><summary>Response schema</summary>
28540<div class="schema-block">{
28541  "configured":     boolean,
28542  "tier":           "cloud" | "server",
28543  "base_url":       string,
28544  "username":       string,
28545  "api_token_set":  boolean,
28546  "space_key":      string,
28547  "parent_page_id": string | null,
28548  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
28549}</div></details>
28550          <p class="curl-heading">Example</p>
28551          <div class="curl-wrap">
28552            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28553  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28554            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
28555          </div>
28556        </div>
28557      </div>
28558
28559      <div class="ep-card">
28560        <div class="ep-header">
28561          <span class="method post">POST</span>
28562          <span class="ep-path">/api/confluence/config</span>
28563          <span class="auth-badge protected">Protected</span>
28564          <span class="ep-desc">Save Confluence configuration</span>
28565          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28566        </div>
28567        <div class="ep-body">
28568          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
28569          <p class="params-heading">Request Body (application/json)</p>
28570          <table class="params">
28571            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28572            <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>
28573            <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>
28574            <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>
28575            <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>
28576            <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>
28577            <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>
28578            <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>
28579          </table>
28580          <details class="schema"><summary>Response schema</summary>
28581<div class="schema-block">{ "ok": true }</div></details>
28582          <p class="curl-heading">Example</p>
28583          <div class="curl-wrap">
28584            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
28585  -H "Authorization: Bearer $SLOC_API_KEY" \
28586  -H "Content-Type: application/json" \
28587  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
28588  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28589            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
28590          </div>
28591        </div>
28592      </div>
28593
28594      <div class="ep-card">
28595        <div class="ep-header">
28596          <span class="method post">POST</span>
28597          <span class="ep-path">/api/confluence/test</span>
28598          <span class="auth-badge protected">Protected</span>
28599          <span class="ep-desc">Test Confluence connection</span>
28600          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28601        </div>
28602        <div class="ep-body">
28603          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
28604          <details class="schema"><summary>Response schema</summary>
28605<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
28606          <p class="curl-heading">Example</p>
28607          <div class="curl-wrap">
28608            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
28609  -H "Authorization: Bearer $SLOC_API_KEY" \
28610  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
28611            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
28612          </div>
28613        </div>
28614      </div>
28615
28616      <div class="ep-card">
28617        <div class="ep-header">
28618          <span class="method post">POST</span>
28619          <span class="ep-path">/api/confluence/post</span>
28620          <span class="auth-badge protected">Protected</span>
28621          <span class="ep-desc">Publish a scan report to Confluence</span>
28622          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28623        </div>
28624        <div class="ep-body">
28625          <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>
28626          <p class="params-heading">Request Body (application/json)</p>
28627          <table class="params">
28628            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28629            <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>
28630            <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>
28631            <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>
28632          </table>
28633          <details class="schema"><summary>Response schema</summary>
28634<div class="schema-block">// 200 OK
28635{ "ok": true, "page_id": string }
28636
28637// 400 / 502 on error
28638{ "ok": false, "error": string }</div></details>
28639          <p class="curl-heading">Example</p>
28640          <div class="curl-wrap">
28641            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
28642  -H "Authorization: Bearer $SLOC_API_KEY" \
28643  -H "Content-Type: application/json" \
28644  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
28645  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
28646            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
28647          </div>
28648        </div>
28649      </div>
28650
28651      <div class="ep-card">
28652        <div class="ep-header">
28653          <span class="method get">GET</span>
28654          <span class="ep-path">/api/confluence/wiki-markup</span>
28655          <span class="auth-badge protected">Protected</span>
28656          <span class="ep-desc">Get Confluence wiki markup for a run</span>
28657          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28658        </div>
28659        <div class="ep-body">
28660          <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>
28661          <p class="params-heading">Query Parameters</p>
28662          <table class="params">
28663            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28664            <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>
28665          </table>
28666          <p class="curl-heading">Example</p>
28667          <div class="curl-wrap">
28668            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28669  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
28670            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
28671          </div>
28672        </div>
28673      </div>
28674    </div>
28675
28676    <!-- Authentication -->
28677    <div class="section">
28678      <h2 class="section-title">Authentication</h2>
28679      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
28680
28681      <div class="ep-card">
28682        <div class="ep-header">
28683          <span class="method get">GET</span>
28684          <span class="ep-path">/auth/login</span>
28685          <span class="auth-badge public">Public</span>
28686          <span class="ep-desc">Login page</span>
28687          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28688        </div>
28689        <div class="ep-body">
28690          <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>
28691          <p class="params-heading">Query Parameters</p>
28692          <table class="params">
28693            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28694            <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>
28695            <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>
28696          </table>
28697        </div>
28698      </div>
28699
28700      <div class="ep-card">
28701        <div class="ep-header">
28702          <span class="method post">POST</span>
28703          <span class="ep-path">/auth/login</span>
28704          <span class="auth-badge public">Public</span>
28705          <span class="ep-desc">Submit credentials and get a session cookie</span>
28706          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28707        </div>
28708        <div class="ep-body">
28709          <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>
28710          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
28711          <table class="params">
28712            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28713            <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>
28714            <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>
28715          </table>
28716          <p class="curl-heading">Example</p>
28717          <div class="curl-wrap">
28718            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
28719  -d "key=$SLOC_API_KEY&amp;next=/" \
28720  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
28721            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
28722          </div>
28723        </div>
28724      </div>
28725    </div>
28726
28727    <!-- Coverage Suggestion -->
28728    <div class="section">
28729      <h2 class="section-title">Coverage Suggestion</h2>
28730
28731      <div class="ep-card">
28732        <div class="ep-header">
28733          <span class="method get">GET</span>
28734          <span class="ep-path">/api/suggest-coverage</span>
28735          <span class="auth-badge protected">Protected</span>
28736          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
28737          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28738        </div>
28739        <div class="ep-body">
28740          <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>
28741          <p class="params-heading">Query Parameters</p>
28742          <table class="params">
28743            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28744            <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>
28745          </table>
28746          <details class="schema"><summary>Response schema</summary>
28747<div class="schema-block">{
28748  "found": string | null,  // absolute path to the coverage file, if detected
28749  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
28750  "hint":  string | null   // shell command to generate coverage if not found
28751}</div></details>
28752          <p class="curl-heading">Example</p>
28753          <div class="curl-wrap">
28754            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28755  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
28756            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
28757          </div>
28758        </div>
28759      </div>
28760    </div>
28761
28762  </div>
28763
28764  <footer class="site-footer">
28765    local code analysis - metrics, history and reports
28766    &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>
28767    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28768    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28769    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28770    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28771  </footer>
28772
28773  <script nonce="{{ csp_nonce }}">
28774    (function () {
28775      var base = window.location.origin;
28776      document.getElementById('base-url').textContent = base;
28777      document.querySelectorAll('.base-url-slot').forEach(function (el) {
28778        el.textContent = base;
28779      });
28780
28781      document.querySelectorAll('.ep-header').forEach(function (hdr) {
28782        hdr.addEventListener('click', function () {
28783          hdr.closest('.ep-card').classList.toggle('open');
28784        });
28785      });
28786
28787      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
28788        btn.addEventListener('click', function () {
28789          var targetId = btn.dataset.target;
28790          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
28791          if (!pre) return;
28792          navigator.clipboard.writeText(pre.textContent).then(function () {
28793            btn.textContent = 'Copied!';
28794            btn.classList.add('copied');
28795            setTimeout(function () {
28796              btn.textContent = 'Copy';
28797              btn.classList.remove('copied');
28798            }, 2000);
28799          });
28800        });
28801      });
28802
28803      var storageKey = 'oxide-sloc-theme';
28804      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
28805      var themeBtn = document.getElementById('theme-toggle');
28806      if (themeBtn) {
28807        themeBtn.addEventListener('click', function () {
28808          var dark = document.body.classList.toggle('dark-theme');
28809          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
28810        });
28811      }
28812      (function() {
28813        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'}];
28814        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);});}
28815        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28816        var btn=document.getElementById('settings-btn');if(!btn)return;
28817        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28818        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>';
28819        document.body.appendChild(m);
28820        var g=document.getElementById('scheme-grid');
28821        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);});
28822        var cl=document.getElementById('settings-close');
28823        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);
28824        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');});
28825        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28826        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28827      })();
28828      (function randomizeWatermarks() {
28829        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28830        if (!wms.length) return;
28831        var placed = [];
28832        function tooClose(top, left) {
28833          for (var i = 0; i < placed.length; i++) {
28834            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
28835            if (dt < 16 && dl < 12) return true;
28836          }
28837          return false;
28838        }
28839        function pick(leftBand) {
28840          for (var attempt = 0; attempt < 50; attempt++) {
28841            var top = Math.random() * 88 + 2;
28842            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28843            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28844          }
28845          var top = Math.random() * 88 + 2;
28846          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28847          placed.push([top, left]); return [top, left];
28848        }
28849        var half = Math.floor(wms.length / 2);
28850        wms.forEach(function (img, i) {
28851          var pos = pick(i < half);
28852          var size = Math.floor(Math.random() * 100 + 120);
28853          var rot = (Math.random() * 360).toFixed(1);
28854          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
28855          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;
28856        });
28857      })();
28858      (function spawnCodeParticles() {
28859        var container = document.getElementById('code-particles');
28860        if (!container) return;
28861        var snippets = [
28862          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
28863          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
28864          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
28865          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
28866          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
28867        ];
28868        var count = 38;
28869        for (var i = 0; i < count; i++) {
28870          (function(idx) {
28871            var el = document.createElement('span');
28872            el.className = 'code-particle';
28873            el.textContent = snippets[idx % snippets.length];
28874            var left = Math.random() * 94 + 2;
28875            var top = Math.random() * 88 + 6;
28876            var dur = (Math.random() * 10 + 9).toFixed(1);
28877            var delay = (Math.random() * 18).toFixed(1);
28878            var rot = (Math.random() * 26 - 13).toFixed(1);
28879            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28880            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
28881            container.appendChild(el);
28882          })(i);
28883        }
28884      })();
28885    }());
28886  </script>
28887</body>
28888</html>
28889"##,
28890    ext = "html"
28891)]
28892struct ApiDocsTemplate {
28893    has_api_key: bool,
28894    csp_nonce: String,
28895    version: &'static str,
28896}
28897
28898#[cfg(test)]
28899mod form_config_tests {
28900    use super::*;
28901    use sloc_config::{
28902        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
28903    };
28904
28905    fn blank_form() -> AnalyzeForm {
28906        AnalyzeForm {
28907            path: ".".to_string(),
28908            git_repo: None,
28909            git_ref: None,
28910            mixed_line_policy: None,
28911            python_docstrings_as_comments: None,
28912            generated_file_detection: None,
28913            minified_file_detection: None,
28914            vendor_directory_detection: None,
28915            include_lockfiles: None,
28916            binary_file_behavior: None,
28917            output_dir: None,
28918            report_title: None,
28919            report_header_footer: None,
28920            include_globs: None,
28921            exclude_globs: None,
28922            submodule_breakdown: None,
28923            coverage_file: None,
28924            continuation_line_policy: None,
28925            blank_in_block_comment_policy: None,
28926            count_compiler_directives: None,
28927            style_col_threshold: None,
28928            style_analysis_enabled: None,
28929            style_score_threshold: None,
28930            style_lang_scope: None,
28931            cocomo_mode: None,
28932            complexity_alert: None,
28933            exclude_duplicates: None,
28934        }
28935    }
28936
28937    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
28938        let mut cfg = sloc_config::AppConfig::default();
28939        apply_form_to_config(&mut cfg, form);
28940        cfg
28941    }
28942
28943    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
28944
28945    #[test]
28946    fn python_docstrings_false_when_unchecked() {
28947        // Checkbox absent in form data (unchecked) → field must be false.
28948        let cfg = apply(&blank_form());
28949        assert!(
28950            !cfg.analysis.python_docstrings_as_comments,
28951            "absent python_docstrings_as_comments must map to false"
28952        );
28953    }
28954
28955    #[test]
28956    fn python_docstrings_true_when_checked() {
28957        // Browser sends "on" (no value= attr on the checkbox).
28958        let mut form = blank_form();
28959        form.python_docstrings_as_comments = Some("on".to_string());
28960        let cfg = apply(&form);
28961        assert!(cfg.analysis.python_docstrings_as_comments);
28962    }
28963
28964    #[test]
28965    fn python_docstrings_true_for_any_non_none_value() {
28966        // The handler uses .is_some() — any non-None value means "checked".
28967        let mut form = blank_form();
28968        form.python_docstrings_as_comments = Some("true".to_string());
28969        assert!(apply(&form).analysis.python_docstrings_as_comments);
28970    }
28971
28972    // ── submodule_breakdown (checkbox with value="enabled") ──
28973
28974    #[test]
28975    fn submodule_breakdown_false_when_unchecked() {
28976        let cfg = apply(&blank_form());
28977        assert!(
28978            !cfg.discovery.submodule_breakdown,
28979            "absent submodule_breakdown must map to false"
28980        );
28981    }
28982
28983    #[test]
28984    fn submodule_breakdown_true_when_value_enabled() {
28985        let mut form = blank_form();
28986        form.submodule_breakdown = Some("enabled".to_string());
28987        assert!(apply(&form).discovery.submodule_breakdown);
28988    }
28989
28990    #[test]
28991    fn submodule_breakdown_false_for_wrong_value() {
28992        // If somehow a value other than "enabled" is sent, it must still be false.
28993        let mut form = blank_form();
28994        form.submodule_breakdown = Some("on".to_string());
28995        assert!(
28996            !apply(&form).discovery.submodule_breakdown,
28997            "submodule_breakdown only becomes true for the exact value 'enabled'"
28998        );
28999    }
29000
29001    // ── generated_file_detection (select: "enabled" | "disabled") ──
29002
29003    #[test]
29004    fn generated_detection_true_when_enabled() {
29005        let mut form = blank_form();
29006        form.generated_file_detection = Some("enabled".to_string());
29007        assert!(apply(&form).analysis.generated_file_detection);
29008    }
29009
29010    #[test]
29011    fn generated_detection_false_when_disabled() {
29012        let mut form = blank_form();
29013        form.generated_file_detection = Some("disabled".to_string());
29014        assert!(!apply(&form).analysis.generated_file_detection);
29015    }
29016
29017    #[test]
29018    fn generated_detection_true_when_absent() {
29019        // None != Some("disabled") → true (safe default)
29020        assert!(
29021            apply(&blank_form()).analysis.generated_file_detection,
29022            "absent field must default to true (detection on)"
29023        );
29024    }
29025
29026    // ── minified_file_detection ──
29027
29028    #[test]
29029    fn minified_detection_false_when_disabled() {
29030        let mut form = blank_form();
29031        form.minified_file_detection = Some("disabled".to_string());
29032        assert!(!apply(&form).analysis.minified_file_detection);
29033    }
29034
29035    #[test]
29036    fn minified_detection_true_when_enabled() {
29037        let mut form = blank_form();
29038        form.minified_file_detection = Some("enabled".to_string());
29039        assert!(apply(&form).analysis.minified_file_detection);
29040    }
29041
29042    #[test]
29043    fn minified_detection_true_when_absent() {
29044        assert!(apply(&blank_form()).analysis.minified_file_detection);
29045    }
29046
29047    // ── vendor_directory_detection ──
29048
29049    #[test]
29050    fn vendor_detection_false_when_disabled() {
29051        let mut form = blank_form();
29052        form.vendor_directory_detection = Some("disabled".to_string());
29053        assert!(!apply(&form).analysis.vendor_directory_detection);
29054    }
29055
29056    #[test]
29057    fn vendor_detection_true_when_enabled() {
29058        let mut form = blank_form();
29059        form.vendor_directory_detection = Some("enabled".to_string());
29060        assert!(apply(&form).analysis.vendor_directory_detection);
29061    }
29062
29063    #[test]
29064    fn vendor_detection_true_when_absent() {
29065        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
29066    }
29067
29068    // ── include_lockfiles (select: "disabled" default | "enabled") ──
29069
29070    #[test]
29071    fn lockfiles_false_when_absent() {
29072        // None == Some("enabled") is false → lockfiles off (correct safe default)
29073        assert!(!apply(&blank_form()).analysis.include_lockfiles);
29074    }
29075
29076    #[test]
29077    fn lockfiles_false_when_disabled() {
29078        let mut form = blank_form();
29079        form.include_lockfiles = Some("disabled".to_string());
29080        assert!(!apply(&form).analysis.include_lockfiles);
29081    }
29082
29083    #[test]
29084    fn lockfiles_true_when_enabled() {
29085        let mut form = blank_form();
29086        form.include_lockfiles = Some("enabled".to_string());
29087        assert!(apply(&form).analysis.include_lockfiles);
29088    }
29089
29090    // ── count_compiler_directives ──
29091
29092    #[test]
29093    fn compiler_directives_true_when_absent() {
29094        assert!(
29095            apply(&blank_form()).analysis.count_compiler_directives,
29096            "absent count_compiler_directives must default to true"
29097        );
29098    }
29099
29100    #[test]
29101    fn compiler_directives_true_when_enabled() {
29102        let mut form = blank_form();
29103        form.count_compiler_directives = Some("enabled".to_string());
29104        assert!(apply(&form).analysis.count_compiler_directives);
29105    }
29106
29107    #[test]
29108    fn compiler_directives_false_when_disabled() {
29109        let mut form = blank_form();
29110        form.count_compiler_directives = Some("disabled".to_string());
29111        assert!(!apply(&form).analysis.count_compiler_directives);
29112    }
29113
29114    // ── mixed_line_policy (enum select) ──
29115
29116    #[test]
29117    fn mixed_policy_unchanged_when_absent() {
29118        // None → if-let does nothing → stays at config default (CodeOnly)
29119        assert_eq!(
29120            apply(&blank_form()).analysis.mixed_line_policy,
29121            MixedLinePolicy::CodeOnly
29122        );
29123    }
29124
29125    #[test]
29126    fn mixed_policy_code_only() {
29127        let mut form = blank_form();
29128        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
29129        assert_eq!(
29130            apply(&form).analysis.mixed_line_policy,
29131            MixedLinePolicy::CodeOnly
29132        );
29133    }
29134
29135    #[test]
29136    fn mixed_policy_code_and_comment() {
29137        let mut form = blank_form();
29138        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
29139        assert_eq!(
29140            apply(&form).analysis.mixed_line_policy,
29141            MixedLinePolicy::CodeAndComment
29142        );
29143    }
29144
29145    #[test]
29146    fn mixed_policy_comment_only() {
29147        let mut form = blank_form();
29148        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
29149        assert_eq!(
29150            apply(&form).analysis.mixed_line_policy,
29151            MixedLinePolicy::CommentOnly
29152        );
29153    }
29154
29155    #[test]
29156    fn mixed_policy_separate_mixed_category() {
29157        let mut form = blank_form();
29158        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
29159        assert_eq!(
29160            apply(&form).analysis.mixed_line_policy,
29161            MixedLinePolicy::SeparateMixedCategory
29162        );
29163    }
29164
29165    // ── binary_file_behavior (enum select) ──
29166
29167    #[test]
29168    fn binary_behavior_skip_when_absent() {
29169        assert_eq!(
29170            apply(&blank_form()).analysis.binary_file_behavior,
29171            BinaryFileBehavior::Skip
29172        );
29173    }
29174
29175    #[test]
29176    fn binary_behavior_skip() {
29177        let mut form = blank_form();
29178        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
29179        assert_eq!(
29180            apply(&form).analysis.binary_file_behavior,
29181            BinaryFileBehavior::Skip
29182        );
29183    }
29184
29185    #[test]
29186    fn binary_behavior_fail() {
29187        let mut form = blank_form();
29188        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
29189        assert_eq!(
29190            apply(&form).analysis.binary_file_behavior,
29191            BinaryFileBehavior::Fail
29192        );
29193    }
29194
29195    // ── continuation_line_policy (enum select) ──
29196
29197    #[test]
29198    fn continuation_policy_each_physical_when_absent() {
29199        assert_eq!(
29200            apply(&blank_form()).analysis.continuation_line_policy,
29201            ContinuationLinePolicy::EachPhysicalLine
29202        );
29203    }
29204
29205    #[test]
29206    fn continuation_policy_collapse_to_logical() {
29207        let mut form = blank_form();
29208        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
29209        assert_eq!(
29210            apply(&form).analysis.continuation_line_policy,
29211            ContinuationLinePolicy::CollapseToLogical
29212        );
29213    }
29214
29215    // ── blank_in_block_comment_policy (enum select) ──
29216
29217    #[test]
29218    fn blank_in_block_comment_count_as_comment_when_absent() {
29219        assert_eq!(
29220            apply(&blank_form()).analysis.blank_in_block_comment_policy,
29221            BlankInBlockCommentPolicy::CountAsComment
29222        );
29223    }
29224
29225    #[test]
29226    fn blank_in_block_comment_count_as_blank() {
29227        let mut form = blank_form();
29228        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
29229        assert_eq!(
29230            apply(&form).analysis.blank_in_block_comment_policy,
29231            BlankInBlockCommentPolicy::CountAsBlank
29232        );
29233    }
29234
29235    // ── style_col_threshold ──
29236
29237    #[test]
29238    fn style_threshold_80() {
29239        let mut form = blank_form();
29240        form.style_col_threshold = Some("80".to_string());
29241        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
29242    }
29243
29244    #[test]
29245    fn style_threshold_100() {
29246        let mut form = blank_form();
29247        form.style_col_threshold = Some("100".to_string());
29248        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
29249    }
29250
29251    #[test]
29252    fn style_threshold_120() {
29253        let mut form = blank_form();
29254        form.style_col_threshold = Some("120".to_string());
29255        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
29256    }
29257
29258    #[test]
29259    fn style_threshold_invalid_value_leaves_default() {
29260        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
29261        let mut cfg = sloc_config::AppConfig::default();
29262        let mut form = blank_form();
29263        form.style_col_threshold = Some("42".to_string());
29264        apply_form_to_config(&mut cfg, &form);
29265        assert_eq!(
29266            cfg.analysis.style_col_threshold, 80,
29267            "invalid threshold must not change config"
29268        );
29269    }
29270
29271    #[test]
29272    fn style_threshold_non_numeric_leaves_default() {
29273        let mut cfg = sloc_config::AppConfig::default();
29274        let mut form = blank_form();
29275        form.style_col_threshold = Some("large".to_string());
29276        apply_form_to_config(&mut cfg, &form);
29277        assert_eq!(cfg.analysis.style_col_threshold, 80);
29278    }
29279
29280    #[test]
29281    fn style_threshold_zero_leaves_default() {
29282        let mut cfg = sloc_config::AppConfig::default();
29283        let mut form = blank_form();
29284        form.style_col_threshold = Some("0".to_string());
29285        apply_form_to_config(&mut cfg, &form);
29286        assert_eq!(cfg.analysis.style_col_threshold, 80);
29287    }
29288
29289    #[test]
29290    fn style_threshold_absent_leaves_default() {
29291        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
29292    }
29293
29294    // ── coverage_file ──
29295
29296    #[test]
29297    fn coverage_file_none_when_absent() {
29298        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
29299    }
29300
29301    #[test]
29302    fn coverage_file_none_when_whitespace_only() {
29303        let mut form = blank_form();
29304        form.coverage_file = Some("   ".to_string());
29305        assert!(
29306            apply(&form).analysis.coverage_file.is_none(),
29307            "whitespace-only coverage_file must be treated as None"
29308        );
29309    }
29310
29311    #[test]
29312    fn coverage_file_set_when_non_empty() {
29313        let mut form = blank_form();
29314        form.coverage_file = Some("coverage/lcov.info".to_string());
29315        assert_eq!(
29316            apply(&form).analysis.coverage_file,
29317            Some(std::path::PathBuf::from("coverage/lcov.info"))
29318        );
29319    }
29320
29321    #[test]
29322    fn coverage_file_trims_whitespace() {
29323        let mut form = blank_form();
29324        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
29325        assert_eq!(
29326            apply(&form).analysis.coverage_file,
29327            Some(std::path::PathBuf::from("coverage/lcov.info"))
29328        );
29329    }
29330
29331    // ── report_title ──
29332
29333    #[test]
29334    fn report_title_unchanged_when_absent() {
29335        let original = sloc_config::AppConfig::default().reporting.report_title;
29336        assert_eq!(apply(&blank_form()).reporting.report_title, original);
29337    }
29338
29339    #[test]
29340    fn report_title_unchanged_when_whitespace_only() {
29341        let original = sloc_config::AppConfig::default().reporting.report_title;
29342        let mut form = blank_form();
29343        form.report_title = Some("   ".to_string());
29344        assert_eq!(
29345            apply(&form).reporting.report_title,
29346            original,
29347            "whitespace-only title must not overwrite the default"
29348        );
29349    }
29350
29351    #[test]
29352    fn report_title_updated_and_trimmed() {
29353        let mut form = blank_form();
29354        form.report_title = Some("  My Project  ".to_string());
29355        assert_eq!(apply(&form).reporting.report_title, "My Project");
29356    }
29357
29358    // ── report_header_footer ──
29359
29360    #[test]
29361    fn header_footer_none_when_absent() {
29362        assert!(apply(&blank_form())
29363            .reporting
29364            .report_header_footer
29365            .is_none());
29366    }
29367
29368    #[test]
29369    fn header_footer_none_when_whitespace_only() {
29370        let mut form = blank_form();
29371        form.report_header_footer = Some("  ".to_string());
29372        assert!(apply(&form).reporting.report_header_footer.is_none());
29373    }
29374
29375    #[test]
29376    fn header_footer_set_and_trimmed() {
29377        let mut form = blank_form();
29378        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
29379        assert_eq!(
29380            apply(&form).reporting.report_header_footer,
29381            Some("Confidential — Internal Use".to_string())
29382        );
29383    }
29384
29385    // ── include_globs / exclude_globs ──
29386
29387    #[test]
29388    fn include_globs_empty_when_absent() {
29389        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
29390    }
29391
29392    #[test]
29393    fn include_globs_newline_separated() {
29394        let mut form = blank_form();
29395        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
29396        assert_eq!(
29397            apply(&form).discovery.include_globs,
29398            vec!["src/**/*.rs", "tests/**/*.rs"]
29399        );
29400    }
29401
29402    #[test]
29403    fn exclude_globs_comma_separated() {
29404        let mut form = blank_form();
29405        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
29406        assert_eq!(
29407            apply(&form).discovery.exclude_globs,
29408            vec!["vendor/**", "node_modules/**"]
29409        );
29410    }
29411
29412    #[test]
29413    fn globs_mixed_separators() {
29414        let mut form = blank_form();
29415        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
29416        assert_eq!(
29417            apply(&form).discovery.exclude_globs,
29418            vec!["a/**", "b/**", "c/**"]
29419        );
29420    }
29421
29422    // ── split_patterns unit tests ──
29423
29424    #[test]
29425    fn split_patterns_none_is_empty() {
29426        assert!(split_patterns(None).is_empty());
29427    }
29428
29429    #[test]
29430    fn split_patterns_empty_string_is_empty() {
29431        assert!(split_patterns(Some("")).is_empty());
29432    }
29433
29434    #[test]
29435    fn split_patterns_whitespace_only_is_empty() {
29436        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
29437    }
29438
29439    #[test]
29440    fn split_patterns_newlines() {
29441        assert_eq!(
29442            split_patterns(Some("a/**\nb/**\nc/**")),
29443            vec!["a/**", "b/**", "c/**"]
29444        );
29445    }
29446
29447    #[test]
29448    fn split_patterns_commas() {
29449        assert_eq!(
29450            split_patterns(Some("a/**,b/**,c/**")),
29451            vec!["a/**", "b/**", "c/**"]
29452        );
29453    }
29454
29455    #[test]
29456    fn split_patterns_mixed() {
29457        assert_eq!(
29458            split_patterns(Some("a/**\nb/**,c/**")),
29459            vec!["a/**", "b/**", "c/**"]
29460        );
29461    }
29462
29463    #[test]
29464    fn split_patterns_trims_whitespace() {
29465        assert_eq!(
29466            split_patterns(Some("  a/**  \n  b/**  ")),
29467            vec!["a/**", "b/**"]
29468        );
29469    }
29470
29471    #[test]
29472    fn split_patterns_filters_empty_entries() {
29473        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
29474    }
29475
29476    #[test]
29477    fn split_patterns_single_entry() {
29478        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
29479    }
29480}
29481
29482#[cfg(test)]
29483mod utility_tests {
29484    use super::*;
29485    use std::net::IpAddr;
29486    use std::time::Duration;
29487
29488    // ── sanitize_project_label ────────────────────────────────────────────────
29489
29490    #[test]
29491    fn sanitize_simple_name() {
29492        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
29493    }
29494
29495    #[test]
29496    fn sanitize_uppercased_lowercased() {
29497        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
29498    }
29499
29500    #[test]
29501    fn sanitize_path_extracts_filename() {
29502        assert_eq!(
29503            sanitize_project_label("/home/user/my-project"),
29504            "my-project"
29505        );
29506    }
29507
29508    #[test]
29509    fn sanitize_path_uses_last_component() {
29510        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
29511    }
29512
29513    #[test]
29514    fn sanitize_spaces_become_hyphens() {
29515        assert_eq!(sanitize_project_label("my project"), "my-project");
29516    }
29517
29518    #[test]
29519    fn sanitize_non_ascii_become_hyphens() {
29520        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
29521    }
29522
29523    #[test]
29524    fn sanitize_all_special_chars_gives_project() {
29525        assert_eq!(sanitize_project_label("!@#$%^"), "project");
29526    }
29527
29528    #[test]
29529    fn sanitize_empty_string_gives_project() {
29530        assert_eq!(sanitize_project_label(""), "project");
29531    }
29532
29533    #[test]
29534    fn sanitize_leading_trailing_hyphens_stripped() {
29535        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
29536    }
29537
29538    #[test]
29539    fn sanitize_alphanumeric_preserved() {
29540        assert_eq!(sanitize_project_label("repo123"), "repo123");
29541    }
29542
29543    #[test]
29544    fn sanitize_dots_become_hyphens() {
29545        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
29546    }
29547
29548    #[test]
29549    fn sanitize_mixed_slashes_uses_filename() {
29550        // The Windows path separator — on all platforms Path::file_name still works
29551        assert_eq!(sanitize_project_label("project-name"), "project-name");
29552    }
29553
29554    // ── IpRateLimiter ─────────────────────────────────────────────────────────
29555
29556    #[test]
29557    fn rate_limiter_allows_first_request() {
29558        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
29559        let ip: IpAddr = "127.0.0.1".parse().unwrap();
29560        assert!(rl.is_allowed(ip));
29561    }
29562
29563    #[test]
29564    fn rate_limiter_blocks_after_limit_reached() {
29565        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
29566        let ip: IpAddr = "10.0.0.1".parse().unwrap();
29567        assert!(rl.is_allowed(ip));
29568        assert!(rl.is_allowed(ip));
29569        assert!(rl.is_allowed(ip));
29570        assert!(!rl.is_allowed(ip), "4th request must be blocked");
29571    }
29572
29573    #[test]
29574    fn rate_limiter_allows_requests_up_to_limit() {
29575        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
29576        let ip: IpAddr = "10.0.0.2".parse().unwrap();
29577        for _ in 0..5 {
29578            assert!(rl.is_allowed(ip));
29579        }
29580        assert!(!rl.is_allowed(ip), "6th request must be blocked");
29581    }
29582
29583    #[test]
29584    fn rate_limiter_different_ips_are_independent() {
29585        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
29586        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
29587        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
29588        assert!(rl.is_allowed(ip1));
29589        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
29590        assert!(rl.is_allowed(ip2), "ip2 must be independent");
29591    }
29592
29593    #[test]
29594    fn rate_limiter_auth_failure_not_locked_below_threshold() {
29595        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29596        let ip: IpAddr = "10.0.0.3".parse().unwrap();
29597        rl.record_auth_failure(ip);
29598        rl.record_auth_failure(ip);
29599        assert!(
29600            !rl.is_auth_locked_out(ip),
29601            "not locked at 2 failures when threshold is 3"
29602        );
29603    }
29604
29605    #[test]
29606    fn rate_limiter_auth_failure_locked_at_threshold() {
29607        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29608        let ip: IpAddr = "10.0.0.4".parse().unwrap();
29609        rl.record_auth_failure(ip);
29610        rl.record_auth_failure(ip);
29611        rl.record_auth_failure(ip);
29612        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
29613    }
29614
29615    #[test]
29616    fn rate_limiter_auth_failure_different_ips_independent() {
29617        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
29618        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
29619        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
29620        rl.record_auth_failure(ip1);
29621        rl.record_auth_failure(ip1);
29622        assert!(rl.is_auth_locked_out(ip1));
29623        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
29624    }
29625
29626    #[test]
29627    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
29628        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
29629        let ip: IpAddr = "127.0.0.2".parse().unwrap();
29630        for _ in 0..100 {
29631            assert!(rl.is_allowed(ip));
29632        }
29633    }
29634
29635    // ── strip_unc_prefix ──────────────────────────────────────────────────────
29636
29637    #[test]
29638    fn strip_unc_plain_path_unchanged() {
29639        let p = PathBuf::from("C:\\Users\\user\\project");
29640        let result = strip_unc_prefix(p.clone());
29641        assert_eq!(result, p);
29642    }
29643
29644    #[test]
29645    fn strip_unc_with_drive_prefix_stripped() {
29646        let p = PathBuf::from(r"\\?\C:\Users\user\project");
29647        let result = strip_unc_prefix(p);
29648        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
29649    }
29650
29651    #[test]
29652    fn strip_unc_with_network_prefix_stripped() {
29653        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
29654        let result = strip_unc_prefix(p);
29655        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
29656    }
29657
29658    #[test]
29659    fn strip_unc_linux_path_unchanged() {
29660        let p = PathBuf::from("/home/user/project");
29661        let result = strip_unc_prefix(p.clone());
29662        assert_eq!(result, p);
29663    }
29664
29665    // ── remote_to_commit_url ──────────────────────────────────────────────────
29666
29667    #[test]
29668    fn remote_to_commit_url_github_https() {
29669        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
29670        assert_eq!(
29671            url,
29672            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29673        );
29674    }
29675
29676    #[test]
29677    fn remote_to_commit_url_github_ssh() {
29678        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
29679        assert_eq!(
29680            url,
29681            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29682        );
29683    }
29684
29685    #[test]
29686    fn remote_to_commit_url_gitlab_uses_dash_commit() {
29687        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
29688        assert_eq!(
29689            url,
29690            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
29691        );
29692    }
29693
29694    #[test]
29695    fn remote_to_commit_url_bitbucket_uses_commits() {
29696        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
29697        assert_eq!(
29698            url,
29699            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
29700        );
29701    }
29702
29703    #[test]
29704    fn remote_to_commit_url_unknown_scheme_returns_none() {
29705        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
29706        assert!(url.is_none());
29707    }
29708
29709    #[test]
29710    fn remote_to_commit_url_ssh_gitlab() {
29711        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
29712        assert!(url.is_some());
29713        let u = url.unwrap();
29714        assert!(
29715            u.contains("/-/commit/sha123"),
29716            "gitlab ssh must use /-/commit/"
29717        );
29718    }
29719
29720    // ── git_clone_dest ────────────────────────────────────────────────────────
29721
29722    #[test]
29723    fn git_clone_dest_github_url_produces_safe_name() {
29724        let dir = PathBuf::from("/tmp/clones");
29725        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29726        let name = dest.file_name().unwrap().to_string_lossy();
29727        assert!(!name.is_empty());
29728        assert!(
29729            name.chars()
29730                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
29731            "clone dest must only contain safe chars, got: {name}"
29732        );
29733    }
29734
29735    #[test]
29736    fn git_clone_dest_is_inside_clones_dir() {
29737        let dir = PathBuf::from("/tmp/clones");
29738        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29739        assert!(
29740            dest.starts_with(&dir),
29741            "clone dest must be inside clones_dir"
29742        );
29743    }
29744
29745    #[test]
29746    fn git_clone_dest_truncates_to_80_chars_max() {
29747        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
29748        let dir = PathBuf::from("/tmp/clones");
29749        let dest = git_clone_dest(&long_url, &dir);
29750        let name = dest.file_name().unwrap().to_string_lossy();
29751        assert!(
29752            name.len() <= 80,
29753            "clone dest name must be at most 80 chars, got {} chars: {name}",
29754            name.len()
29755        );
29756    }
29757
29758    #[test]
29759    fn git_clone_dest_special_chars_replaced_with_underscore() {
29760        let dir = PathBuf::from("/tmp/clones");
29761        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
29762        let name = dest.file_name().unwrap().to_string_lossy();
29763        assert!(
29764            !name.contains('@') && !name.contains(':') && !name.contains('/'),
29765            "special chars must be replaced in clone dest, got: {name}"
29766        );
29767    }
29768
29769    #[test]
29770    fn git_clone_dest_different_urls_differ() {
29771        let dir = PathBuf::from("/tmp/clones");
29772        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
29773        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
29774        assert_ne!(
29775            a, b,
29776            "different repos must produce different clone dest names"
29777        );
29778    }
29779
29780    #[test]
29781    fn git_clone_dest_same_url_same_result() {
29782        let dir = PathBuf::from("/tmp/clones");
29783        let url = "https://github.com/owner/repo.git";
29784        assert_eq!(
29785            git_clone_dest(url, &dir),
29786            git_clone_dest(url, &dir),
29787            "same URL must always give same clone dest"
29788        );
29789    }
29790
29791    // ── fmt_delta ─────────────────────────────────────────────────────────────
29792
29793    #[test]
29794    fn fmt_delta_positive_has_plus_prefix() {
29795        assert_eq!(fmt_delta(5), "+5");
29796    }
29797
29798    #[test]
29799    fn fmt_delta_negative_no_plus_prefix() {
29800        assert_eq!(fmt_delta(-3), "-3");
29801    }
29802
29803    #[test]
29804    fn fmt_delta_zero() {
29805        assert_eq!(fmt_delta(0), "0");
29806    }
29807
29808    // ── delta_class ───────────────────────────────────────────────────────────
29809
29810    #[test]
29811    fn delta_class_positive_is_pos() {
29812        assert_eq!(delta_class(1), "pos");
29813    }
29814
29815    #[test]
29816    fn delta_class_negative_is_neg() {
29817        assert_eq!(delta_class(-1), "neg");
29818    }
29819
29820    #[test]
29821    fn delta_class_zero_is_zero_class() {
29822        assert_eq!(delta_class(0), "zero");
29823    }
29824
29825    // ── fmt_pct ───────────────────────────────────────────────────────────────
29826
29827    #[test]
29828    fn fmt_pct_zero_baseline_returns_em_dash() {
29829        assert_eq!(fmt_pct(100, 0), "\u{2014}");
29830    }
29831
29832    #[test]
29833    fn fmt_pct_positive_delta_has_plus_sign() {
29834        let result = fmt_pct(10, 100);
29835        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
29836    }
29837
29838    #[test]
29839    fn fmt_pct_negative_delta_no_plus_sign() {
29840        let result = fmt_pct(-10, 100);
29841        assert!(!result.starts_with('+'), "unexpected + in: {result}");
29842        assert!(result.contains('%'));
29843    }
29844
29845    #[test]
29846    fn fmt_pct_near_zero_returns_pm_zero() {
29847        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
29848    }
29849
29850    // ── summary_delta ─────────────────────────────────────────────────────────
29851
29852    #[test]
29853    fn summary_delta_no_prev_returns_dash_na() {
29854        let (display, class) = summary_delta(10, None);
29855        assert_eq!(display, "\u{2014}");
29856        assert_eq!(class, "na");
29857    }
29858
29859    #[test]
29860    fn summary_delta_increase_is_positive() {
29861        let (display, class) = summary_delta(15, Some(10));
29862        assert_eq!(display, "+5");
29863        assert_eq!(class, "pos");
29864    }
29865
29866    #[test]
29867    fn summary_delta_decrease_is_negative() {
29868        let (display, class) = summary_delta(5, Some(10));
29869        assert_eq!(display, "-5");
29870        assert_eq!(class, "neg");
29871    }
29872
29873    // ── nth_weekday_of_month ──────────────────────────────────────────────────
29874
29875    #[test]
29876    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
29877        use chrono::Datelike;
29878        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
29879        assert_eq!(d.year(), 2024);
29880        assert_eq!(d.month(), 1);
29881        assert_eq!(d.weekday(), chrono::Weekday::Mon);
29882        assert!(d.day() <= 7);
29883    }
29884
29885    #[test]
29886    fn nth_weekday_second_sunday_march_2024_is_10th() {
29887        use chrono::Datelike;
29888        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
29889        assert_eq!(d.weekday(), chrono::Weekday::Sun);
29890        assert_eq!(d.month(), 3);
29891        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
29892    }
29893
29894    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
29895
29896    #[test]
29897    fn is_pacific_dst_july_is_true() {
29898        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29899        assert!(is_pacific_dst(dt), "July must be PDT");
29900    }
29901
29902    #[test]
29903    fn is_pacific_dst_january_is_false() {
29904        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29905        assert!(!is_pacific_dst(dt), "January must be PST");
29906    }
29907
29908    #[test]
29909    fn fmt_la_time_summer_shows_pdt() {
29910        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29911        let result = fmt_la_time(dt);
29912        assert!(
29913            result.ends_with("PDT"),
29914            "summer must use PDT, got: {result}"
29915        );
29916    }
29917
29918    #[test]
29919    fn fmt_la_time_winter_shows_pst() {
29920        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29921        let result = fmt_la_time(dt);
29922        assert!(
29923            result.ends_with("PST"),
29924            "winter must use PST, got: {result}"
29925        );
29926    }
29927
29928    #[test]
29929    fn fmt_la_time_meta_summer_shows_pdt() {
29930        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
29931        let result = fmt_la_time_meta(dt);
29932        assert!(
29933            result.ends_with("PDT"),
29934            "meta summer must use PDT, got: {result}"
29935        );
29936    }
29937
29938    #[test]
29939    fn fmt_la_time_meta_winter_shows_pst() {
29940        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
29941        let result = fmt_la_time_meta(dt);
29942        assert!(
29943            result.ends_with("PST"),
29944            "meta winter must use PST, got: {result}"
29945        );
29946    }
29947
29948    // ── fmt_git_date ──────────────────────────────────────────────────────────
29949
29950    #[test]
29951    fn fmt_git_date_valid_iso_returns_some() {
29952        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
29953    }
29954
29955    #[test]
29956    fn fmt_git_date_invalid_returns_none() {
29957        assert!(fmt_git_date("not-a-date").is_none());
29958    }
29959
29960    // ── format_number ─────────────────────────────────────────────────────────
29961
29962    #[test]
29963    fn format_number_zero() {
29964        assert_eq!(format_number(0), "0");
29965    }
29966
29967    #[test]
29968    fn format_number_three_digits_no_comma() {
29969        assert_eq!(format_number(999), "999");
29970    }
29971
29972    #[test]
29973    fn format_number_four_digits_has_comma() {
29974        assert_eq!(format_number(1000), "1,000");
29975    }
29976
29977    #[test]
29978    fn format_number_seven_digits_two_commas() {
29979        assert_eq!(format_number(1_234_567), "1,234,567");
29980    }
29981
29982    #[test]
29983    fn format_number_one_million() {
29984        assert_eq!(format_number(1_000_000), "1,000,000");
29985    }
29986
29987    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
29988
29989    #[test]
29990    fn badge_text_px_empty_is_zero() {
29991        assert_eq!(badge_text_px(""), 0);
29992    }
29993
29994    #[test]
29995    fn badge_text_px_narrow_chars_smaller_than_normal() {
29996        assert!(
29997            badge_text_px("if") < badge_text_px("ab"),
29998            "'if' must be narrower than 'ab'"
29999        );
30000    }
30001
30002    #[test]
30003    fn badge_text_px_m_is_wider_than_a() {
30004        assert!(
30005            badge_text_px("m") > badge_text_px("a"),
30006            "'m' must be wider than 'a'"
30007        );
30008    }
30009
30010    #[test]
30011    fn render_badge_svg_contains_label_and_value() {
30012        let svg = render_badge_svg("coverage", "95%", "#4c1");
30013        assert!(svg.contains("coverage") && svg.contains("95%"));
30014    }
30015
30016    #[test]
30017    fn render_badge_svg_contains_color() {
30018        let svg = render_badge_svg("sloc", "12K", "#e05d44");
30019        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
30020    }
30021
30022    #[test]
30023    fn render_badge_svg_escapes_ampersand_in_label() {
30024        let svg = render_badge_svg("test&label", "ok", "#4c1");
30025        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
30026    }
30027
30028    // ── build_pdf_filename ────────────────────────────────────────────────────
30029
30030    #[test]
30031    fn build_pdf_filename_slugifies_title() {
30032        let name = build_pdf_filename("My Project Report", "abc-def-1234");
30033        assert!(
30034            name.starts_with("my_project_report_")
30035                && std::path::Path::new(&name)
30036                    .extension()
30037                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30038        );
30039    }
30040
30041    #[test]
30042    fn build_pdf_filename_uses_last_run_id_segment() {
30043        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
30044        assert!(name.contains("ABCD"), "must use last segment of run_id");
30045    }
30046
30047    #[test]
30048    fn build_pdf_filename_empty_title_uses_report_prefix() {
30049        let name = build_pdf_filename("", "abc-def-9999");
30050        assert!(
30051            name.starts_with("report_")
30052                && std::path::Path::new(&name)
30053                    .extension()
30054                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30055        );
30056    }
30057
30058    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
30059
30060    #[test]
30061    fn swap_chart_js_replaces_inline_block() {
30062        let html = "<html><head><script>// inline source</script></head><body></body></html>";
30063        let result = swap_inline_chart_js_for_static(html.to_string());
30064        assert!(result.contains(r#"src="/static/chart-report.js""#));
30065        assert!(!result.contains("inline source"));
30066    }
30067
30068    #[test]
30069    fn swap_chart_js_no_head_returns_unchanged() {
30070        let html = "<body>no head here</body>";
30071        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
30072    }
30073
30074    #[test]
30075    fn swap_chart_js_no_script_in_head_unchanged() {
30076        let html = "<html><head><style>.x{}</style></head><body></body></html>";
30077        let result = swap_inline_chart_js_for_static(html.to_string());
30078        assert!(!result.contains("chart-report.js"));
30079    }
30080
30081    // ── patch_html_nonce ──────────────────────────────────────────────────────
30082
30083    #[test]
30084    fn patch_html_nonce_replaces_old_nonce() {
30085        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
30086        let result = patch_html_nonce(html, "new-nonce-456");
30087        assert!(result.contains(r#"nonce="new-nonce-456""#));
30088        assert!(!result.contains("old-nonce-123"));
30089    }
30090
30091    #[test]
30092    fn patch_html_nonce_injects_into_bare_style() {
30093        let html = "<style>body{color:red;}</style>";
30094        let result = patch_html_nonce(html, "fresh-nonce");
30095        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
30096    }
30097
30098    #[test]
30099    fn patch_html_nonce_injects_into_bare_script() {
30100        let html = "<script>console.log(1);</script>";
30101        let result = patch_html_nonce(html, "abc");
30102        assert!(result.contains(r#"<script nonce="abc">"#));
30103    }
30104
30105    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
30106
30107    #[test]
30108    fn is_html_report_file_result_html_matches() {
30109        let dir = tempfile::tempdir().unwrap();
30110        let path = dir.path().join("result_20240101.html");
30111        std::fs::write(&path, b"<html></html>").unwrap();
30112        assert!(is_html_report_file(&path));
30113    }
30114
30115    #[test]
30116    fn is_html_report_file_report_html_matches() {
30117        let dir = tempfile::tempdir().unwrap();
30118        let path = dir.path().join("report_abc.html");
30119        std::fs::write(&path, b"<html></html>").unwrap();
30120        assert!(is_html_report_file(&path));
30121    }
30122
30123    #[test]
30124    fn is_html_report_file_index_html_does_not_match() {
30125        let dir = tempfile::tempdir().unwrap();
30126        let path = dir.path().join("index.html");
30127        std::fs::write(&path, b"<html></html>").unwrap();
30128        assert!(!is_html_report_file(&path));
30129    }
30130
30131    #[test]
30132    fn is_html_report_file_nonexistent_returns_false() {
30133        assert!(!is_html_report_file(Path::new(
30134            "/nonexistent/result_xyz.html"
30135        )));
30136    }
30137
30138    #[test]
30139    fn find_html_report_in_dir_finds_result_html() {
30140        let dir = tempfile::tempdir().unwrap();
30141        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
30142        assert!(find_html_report_in_dir(dir.path()).is_some());
30143    }
30144
30145    #[test]
30146    fn find_html_report_in_dir_empty_returns_none() {
30147        let dir = tempfile::tempdir().unwrap();
30148        assert!(find_html_report_in_dir(dir.path()).is_none());
30149    }
30150
30151    #[test]
30152    fn find_html_report_in_tree_finds_in_subdir() {
30153        let dir = tempfile::tempdir().unwrap();
30154        let subdir = dir.path().join("run-001");
30155        std::fs::create_dir_all(&subdir).unwrap();
30156        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
30157        assert!(find_html_report_in_tree(dir.path()).is_some());
30158    }
30159
30160    // ── derive_project_label ──────────────────────────────────────────────────
30161
30162    #[test]
30163    fn derive_project_label_with_git_repo_and_ref() {
30164        let label = derive_project_label(
30165            Some("https://github.com/owner/my-repo.git"),
30166            Some("main"),
30167            "/fallback/path",
30168        );
30169        assert!(!label.is_empty(), "label must not be empty");
30170        assert!(
30171            label.contains("my") || label.contains("repo"),
30172            "got: {label}"
30173        );
30174    }
30175
30176    #[test]
30177    fn derive_project_label_fallback_to_path() {
30178        let label = derive_project_label(None, None, "/path/to/myproject");
30179        assert_eq!(label, "myproject");
30180    }
30181
30182    #[test]
30183    fn derive_project_label_empty_git_fields_use_path() {
30184        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
30185        assert_eq!(label, "cool-app");
30186    }
30187
30188    // ── derive_file_stem ──────────────────────────────────────────────────────
30189
30190    #[test]
30191    fn derive_file_stem_with_commit_appends_sha() {
30192        assert_eq!(
30193            derive_file_stem("myproject", Some("a1b2c3")),
30194            "myproject_a1b2c3"
30195        );
30196    }
30197
30198    #[test]
30199    fn derive_file_stem_without_commit_returns_label() {
30200        assert_eq!(derive_file_stem("myproject", None), "myproject");
30201    }
30202
30203    #[test]
30204    fn derive_file_stem_empty_commit_returns_label() {
30205        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
30206    }
30207
30208    // ── split_patterns ────────────────────────────────────────────────────────
30209
30210    #[test]
30211    fn split_patterns_none_is_empty() {
30212        assert!(split_patterns(None).is_empty());
30213    }
30214
30215    #[test]
30216    fn split_patterns_empty_string_is_empty() {
30217        assert!(split_patterns(Some("")).is_empty());
30218    }
30219
30220    #[test]
30221    fn split_patterns_comma_separated() {
30222        assert_eq!(
30223            split_patterns(Some("foo,bar,baz")),
30224            vec!["foo", "bar", "baz"]
30225        );
30226    }
30227
30228    #[test]
30229    fn split_patterns_newline_separated() {
30230        assert_eq!(
30231            split_patterns(Some("foo\nbar\nbaz")),
30232            vec!["foo", "bar", "baz"]
30233        );
30234    }
30235
30236    #[test]
30237    fn split_patterns_trims_whitespace() {
30238        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
30239    }
30240
30241    // ── make_git_label ────────────────────────────────────────────────────────
30242
30243    #[test]
30244    fn make_git_label_empty_repo_empty_result() {
30245        assert_eq!(make_git_label("", "main"), "");
30246    }
30247
30248    #[test]
30249    fn make_git_label_empty_ref_empty_result() {
30250        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
30251    }
30252
30253    #[test]
30254    fn make_git_label_basic_format() {
30255        assert_eq!(
30256            make_git_label("https://github.com/owner/my-repo.git", "main"),
30257            "my-repo_at_main_sloc"
30258        );
30259    }
30260
30261    #[test]
30262    fn make_git_label_slash_in_ref_replaced() {
30263        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
30264        assert!(
30265            !label.contains('/'),
30266            "slash in ref must be replaced: {label}"
30267        );
30268    }
30269
30270    // ── format_dir_size ───────────────────────────────────────────────────────
30271
30272    #[test]
30273    fn format_dir_size_bytes() {
30274        assert_eq!(format_dir_size(500), "500 B");
30275    }
30276
30277    #[test]
30278    fn format_dir_size_kilobytes() {
30279        assert_eq!(format_dir_size(2048), "2 KB");
30280    }
30281
30282    #[test]
30283    fn format_dir_size_megabytes() {
30284        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
30285    }
30286
30287    #[test]
30288    fn format_dir_size_gigabytes() {
30289        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
30290    }
30291
30292    #[test]
30293    fn format_dir_size_zero() {
30294        assert_eq!(format_dir_size(0), "0 B");
30295    }
30296
30297    // ── civil_from_days ───────────────────────────────────────────────────────
30298
30299    #[test]
30300    fn civil_from_days_epoch() {
30301        assert_eq!(civil_from_days(0), (1970, 1, 1));
30302    }
30303
30304    #[test]
30305    fn civil_from_days_one_year_later() {
30306        assert_eq!(civil_from_days(365), (1971, 1, 1));
30307    }
30308
30309    #[test]
30310    fn civil_from_days_31_days_is_feb_1_1970() {
30311        assert_eq!(civil_from_days(31), (1970, 2, 1));
30312    }
30313
30314    // ── format_system_time ────────────────────────────────────────────────────
30315
30316    #[test]
30317    fn format_system_time_unix_epoch_formats_correctly() {
30318        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
30319    }
30320
30321    #[test]
30322    fn format_system_time_31_days_after_epoch() {
30323        let t = UNIX_EPOCH + Duration::from_hours(744);
30324        assert_eq!(format_system_time(t), "1970-02-01 00:00");
30325    }
30326
30327    #[test]
30328    fn format_system_time_before_epoch_returns_dash() {
30329        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
30330            assert_eq!(format_system_time(before), "-");
30331        }
30332    }
30333
30334    // ── detect_language_name ──────────────────────────────────────────────────
30335
30336    #[test]
30337    fn detect_language_name_dot_c() {
30338        assert_eq!(detect_language_name("main.c"), Some("C"));
30339    }
30340
30341    #[test]
30342    fn detect_language_name_dot_h() {
30343        assert_eq!(detect_language_name("defs.h"), Some("C"));
30344    }
30345
30346    #[test]
30347    fn detect_language_name_dot_cpp() {
30348        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
30349    }
30350
30351    #[test]
30352    fn detect_language_name_dot_py() {
30353        assert_eq!(detect_language_name("script.py"), Some("Python"));
30354    }
30355
30356    #[test]
30357    fn detect_language_name_dot_ps1() {
30358        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
30359    }
30360
30361    #[test]
30362    fn detect_language_name_dot_cs() {
30363        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
30364    }
30365
30366    #[test]
30367    fn detect_language_name_dot_sh() {
30368        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
30369    }
30370
30371    #[test]
30372    fn detect_language_name_unknown_txt() {
30373        assert_eq!(detect_language_name("notes.txt"), None);
30374    }
30375
30376    // ── language_icon_file ────────────────────────────────────────────────────
30377
30378    #[test]
30379    fn language_icon_file_c() {
30380        assert_eq!(language_icon_file("C"), Some("c.png"));
30381    }
30382
30383    #[test]
30384    fn language_icon_file_python() {
30385        assert_eq!(language_icon_file("Python"), Some("python.png"));
30386    }
30387
30388    #[test]
30389    fn language_icon_file_dockerfile() {
30390        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
30391    }
30392
30393    #[test]
30394    fn language_icon_file_rust_is_none() {
30395        assert!(language_icon_file("Rust").is_none());
30396    }
30397
30398    #[test]
30399    fn language_icon_file_unknown_is_none() {
30400        assert!(language_icon_file("Fortran").is_none());
30401    }
30402
30403    // ── language_inline_svg ───────────────────────────────────────────────────
30404
30405    #[test]
30406    fn language_inline_svg_rust_is_svg() {
30407        let svg = language_inline_svg("Rust").unwrap();
30408        assert!(svg.starts_with("<svg"));
30409    }
30410
30411    #[test]
30412    fn language_inline_svg_typescript_is_some() {
30413        assert!(language_inline_svg("TypeScript").is_some());
30414    }
30415
30416    #[test]
30417    fn language_inline_svg_unknown_is_none() {
30418        assert!(language_inline_svg("Fortran").is_none());
30419    }
30420
30421    // ── classify_preview_file ─────────────────────────────────────────────────
30422
30423    #[test]
30424    fn classify_preview_file_c_supported() {
30425        assert!(matches!(
30426            classify_preview_file("main.c"),
30427            PreviewKind::Supported
30428        ));
30429    }
30430
30431    #[test]
30432    fn classify_preview_file_python_supported() {
30433        assert!(matches!(
30434            classify_preview_file("script.py"),
30435            PreviewKind::Supported
30436        ));
30437    }
30438
30439    #[test]
30440    fn classify_preview_file_png_skipped() {
30441        assert!(matches!(
30442            classify_preview_file("image.png"),
30443            PreviewKind::Skipped
30444        ));
30445    }
30446
30447    #[test]
30448    fn classify_preview_file_zip_skipped() {
30449        assert!(matches!(
30450            classify_preview_file("archive.zip"),
30451            PreviewKind::Skipped
30452        ));
30453    }
30454
30455    #[test]
30456    fn classify_preview_file_min_js_skipped() {
30457        assert!(matches!(
30458            classify_preview_file("bundle.min.js"),
30459            PreviewKind::Skipped
30460        ));
30461    }
30462
30463    #[test]
30464    fn classify_preview_file_rs_unsupported() {
30465        assert!(matches!(
30466            classify_preview_file("main.rs"),
30467            PreviewKind::Unsupported
30468        ));
30469    }
30470
30471    // ── preview_relative_path ─────────────────────────────────────────────────
30472
30473    #[test]
30474    fn preview_relative_path_strips_root() {
30475        let root = PathBuf::from("/project");
30476        let path = PathBuf::from("/project/src/main.c");
30477        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
30478    }
30479
30480    #[test]
30481    fn preview_relative_path_unrooted_includes_filename() {
30482        let root = PathBuf::from("/other");
30483        let path = PathBuf::from("/project/src/main.c");
30484        let result = preview_relative_path(&root, &path);
30485        assert!(result.contains("main.c"));
30486    }
30487
30488    #[test]
30489    fn preview_relative_path_uses_forward_slashes() {
30490        let root = PathBuf::from("/project");
30491        let path = PathBuf::from("/project/a/b/c.py");
30492        assert!(!preview_relative_path(&root, &path).contains('\\'));
30493    }
30494
30495    // ── wildcard_match ────────────────────────────────────────────────────────
30496
30497    #[test]
30498    fn wildcard_match_exact_equal() {
30499        assert!(wildcard_match("foo", "foo"));
30500    }
30501
30502    #[test]
30503    fn wildcard_match_exact_mismatch() {
30504        assert!(!wildcard_match("foo", "bar"));
30505    }
30506
30507    #[test]
30508    fn wildcard_match_star_suffix() {
30509        assert!(wildcard_match("*.rs", "main.rs"));
30510    }
30511
30512    #[test]
30513    fn wildcard_match_star_middle_requires_suffix() {
30514        assert!(!wildcard_match("a*b", "ac"));
30515    }
30516
30517    #[test]
30518    fn wildcard_match_question_mark_single_char() {
30519        assert!(wildcard_match("f?o", "foo"));
30520    }
30521
30522    #[test]
30523    fn wildcard_match_double_star_nested() {
30524        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
30525    }
30526
30527    #[test]
30528    fn wildcard_match_star_directory_entry() {
30529        assert!(wildcard_match("vendor/*", "vendor/crate"));
30530    }
30531
30532    #[test]
30533    fn wildcard_match_no_cross_prefix() {
30534        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
30535    }
30536
30537    // ── should_skip_preview_directory ────────────────────────────────────────
30538
30539    #[test]
30540    fn should_skip_empty_relative_is_false() {
30541        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
30542    }
30543
30544    #[test]
30545    fn should_skip_matching_pattern() {
30546        assert!(should_skip_preview_directory(
30547            "vendor",
30548            &["vendor".to_string()]
30549        ));
30550    }
30551
30552    #[test]
30553    fn should_skip_non_matching() {
30554        assert!(!should_skip_preview_directory(
30555            "src",
30556            &["vendor".to_string()]
30557        ));
30558    }
30559
30560    #[test]
30561    fn should_skip_wildcard_prefix() {
30562        assert!(should_skip_preview_directory(
30563            "target/debug",
30564            &["target*".to_string()]
30565        ));
30566    }
30567
30568    // ── should_include_preview_file ───────────────────────────────────────────
30569
30570    #[test]
30571    fn should_include_empty_relative_always_true() {
30572        assert!(should_include_preview_file("", &[], &[]));
30573    }
30574
30575    #[test]
30576    fn should_include_no_patterns_includes_all() {
30577        assert!(should_include_preview_file("src/main.c", &[], &[]));
30578    }
30579
30580    #[test]
30581    fn should_include_excluded_by_pattern() {
30582        assert!(!should_include_preview_file(
30583            "vendor/lib.c",
30584            &[],
30585            &["vendor/*".to_string()]
30586        ));
30587    }
30588
30589    #[test]
30590    fn should_include_include_pattern_filters() {
30591        assert!(!should_include_preview_file(
30592            "tests/test_foo.c",
30593            &["src/*".to_string()],
30594            &[]
30595        ));
30596    }
30597
30598    // ── escape_html ───────────────────────────────────────────────────────────
30599
30600    #[test]
30601    fn escape_html_ampersand() {
30602        assert_eq!(escape_html("a&b"), "a&amp;b");
30603    }
30604
30605    #[test]
30606    fn escape_html_angle_brackets() {
30607        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
30608    }
30609
30610    #[test]
30611    fn escape_html_double_quote() {
30612        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
30613    }
30614
30615    #[test]
30616    fn escape_html_single_quote() {
30617        assert_eq!(escape_html("it's"), "it&#39;s");
30618    }
30619
30620    #[test]
30621    fn escape_html_plain_text_unchanged() {
30622        assert_eq!(escape_html("hello world"), "hello world");
30623    }
30624
30625    // ── sum_added / removed / unmodified code lines ───────────────────────────
30626
30627    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
30628        sloc_core::ScanComparison {
30629            summary: sloc_core::SummaryDelta {
30630                baseline_run_id: "base".to_string(),
30631                current_run_id: "curr".to_string(),
30632                baseline_timestamp: chrono::Utc::now(),
30633                current_timestamp: chrono::Utc::now(),
30634                baseline_files: 4,
30635                current_files: 4,
30636                files_analyzed_delta: 0,
30637                baseline_code: 330,
30638                current_code: 400,
30639                code_lines_delta: 70,
30640                baseline_comments: 0,
30641                current_comments: 0,
30642                comment_lines_delta: 0,
30643                blank_lines_delta: 0,
30644                total_lines_delta: 70,
30645                coverage_lines_hit_delta: None,
30646                coverage_line_pct_delta: None,
30647                baseline_coverage_line_pct: None,
30648                current_coverage_line_pct: None,
30649            },
30650            file_deltas: vec![
30651                sloc_core::FileDelta {
30652                    relative_path: "added.rs".to_string(),
30653                    language: Some("Rust".to_string()),
30654                    status: FileChangeStatus::Added,
30655                    baseline_code: 0,
30656                    current_code: 100,
30657                    code_delta: 100,
30658                    baseline_comment: 0,
30659                    current_comment: 0,
30660                    comment_delta: 0,
30661                    baseline_blank: 0,
30662                    current_blank: 0,
30663                    blank_delta: 0,
30664                    total_delta: 100,
30665                },
30666                sloc_core::FileDelta {
30667                    relative_path: "removed.rs".to_string(),
30668                    language: Some("Rust".to_string()),
30669                    status: FileChangeStatus::Removed,
30670                    baseline_code: 50,
30671                    current_code: 0,
30672                    code_delta: -50,
30673                    baseline_comment: 0,
30674                    current_comment: 0,
30675                    comment_delta: 0,
30676                    baseline_blank: 0,
30677                    current_blank: 0,
30678                    blank_delta: 0,
30679                    total_delta: -50,
30680                },
30681                sloc_core::FileDelta {
30682                    relative_path: "modified.rs".to_string(),
30683                    language: Some("Rust".to_string()),
30684                    status: FileChangeStatus::Modified,
30685                    baseline_code: 80,
30686                    current_code: 100,
30687                    code_delta: 20,
30688                    baseline_comment: 0,
30689                    current_comment: 0,
30690                    comment_delta: 0,
30691                    baseline_blank: 0,
30692                    current_blank: 0,
30693                    blank_delta: 0,
30694                    total_delta: 20,
30695                },
30696                sloc_core::FileDelta {
30697                    relative_path: "unchanged.rs".to_string(),
30698                    language: Some("Rust".to_string()),
30699                    status: FileChangeStatus::Unchanged,
30700                    baseline_code: 200,
30701                    current_code: 200,
30702                    code_delta: 0,
30703                    baseline_comment: 0,
30704                    current_comment: 0,
30705                    comment_delta: 0,
30706                    baseline_blank: 0,
30707                    current_blank: 0,
30708                    blank_delta: 0,
30709                    total_delta: 0,
30710                },
30711            ],
30712            files_added: 1,
30713            files_removed: 1,
30714            files_modified: 1,
30715            files_unchanged: 1,
30716        }
30717    }
30718
30719    #[test]
30720    fn sum_added_counts_added_and_positive_modified() {
30721        let cmp = make_mixed_scan_comparison();
30722        assert_eq!(sum_added_code_lines(&cmp), 120);
30723    }
30724
30725    #[test]
30726    fn sum_removed_counts_removed_baseline() {
30727        let cmp = make_mixed_scan_comparison();
30728        assert_eq!(sum_removed_code_lines(&cmp), 50);
30729    }
30730
30731    #[test]
30732    fn sum_unmodified_counts_unchanged_files() {
30733        let cmp = make_mixed_scan_comparison();
30734        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
30735    }
30736
30737    // ── detect_coverage_tool ──────────────────────────────────────────────────
30738
30739    #[test]
30740    fn detect_coverage_tool_rust_project() {
30741        let dir = tempfile::tempdir().unwrap();
30742        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
30743        let (tool, cmd) = detect_coverage_tool(dir.path());
30744        assert_eq!(tool, Some("cargo-llvm-cov"));
30745        assert!(cmd.is_some());
30746    }
30747
30748    #[test]
30749    fn detect_coverage_tool_java_gradle() {
30750        let dir = tempfile::tempdir().unwrap();
30751        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
30752        let (tool, _) = detect_coverage_tool(dir.path());
30753        assert_eq!(tool, Some("jacoco"));
30754    }
30755
30756    #[test]
30757    fn detect_coverage_tool_python_pyproject() {
30758        let dir = tempfile::tempdir().unwrap();
30759        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
30760        let (tool, _) = detect_coverage_tool(dir.path());
30761        assert_eq!(tool, Some("pytest-cov"));
30762    }
30763
30764    #[test]
30765    fn detect_coverage_tool_unknown_project() {
30766        let dir = tempfile::tempdir().unwrap();
30767        let (tool, cmd) = detect_coverage_tool(dir.path());
30768        assert!(tool.is_none() && cmd.is_none());
30769    }
30770
30771    // ── sanitize_path_str / display_path ─────────────────────────────────────
30772
30773    #[test]
30774    fn sanitize_path_str_unc_drive_stripped() {
30775        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
30776    }
30777
30778    #[test]
30779    fn sanitize_path_str_unc_network_stripped() {
30780        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
30781    }
30782
30783    #[test]
30784    fn sanitize_path_str_plain_path_unchanged() {
30785        assert_eq!(
30786            sanitize_path_str("/home/user/project"),
30787            "/home/user/project"
30788        );
30789    }
30790
30791    #[test]
30792    fn display_path_plain_linux_unchanged() {
30793        assert_eq!(
30794            display_path(Path::new("/home/user/project")),
30795            "/home/user/project"
30796        );
30797    }
30798
30799    #[test]
30800    fn display_path_unc_drive_stripped() {
30801        let result = display_path(Path::new(r"\\?\C:\Users\user"));
30802        assert_eq!(result, r"C:\Users\user");
30803    }
30804
30805    #[test]
30806    fn display_path_unc_network_stripped() {
30807        let result = display_path(Path::new(r"\\?\UNC\server\share"));
30808        assert_eq!(result, r"\\server\share");
30809    }
30810}
30811
30812#[cfg(test)]
30813mod coverage_boost_unit_tests {
30814    use super::*;
30815    use std::path::{Path, PathBuf};
30816
30817    // Both scenarios live in one test (sequential, under a Tokio runtime) because
30818    // load_runtime_security_config spawns a pruning task and mutates process-global
30819    // env vars — parallel sub-tests would race on both.
30820    #[tokio::test]
30821    async fn runtime_security_config_scenarios() {
30822        std::env::remove_var("SLOC_API_KEYS");
30823        std::env::remove_var("SLOC_API_KEY");
30824        std::env::remove_var("SLOC_TLS_CERT");
30825        std::env::remove_var("SLOC_TLS_KEY");
30826        std::env::remove_var("SLOC_TRUST_PROXY");
30827        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30828        let cfg = load_runtime_security_config(false);
30829        assert!(cfg.api_keys.is_empty());
30830        assert!(!cfg.tls_enabled);
30831        assert!(!cfg.trust_proxy);
30832
30833        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
30834        std::env::set_var("SLOC_TRUST_PROXY", "1");
30835        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
30836        std::env::set_var("SLOC_RATE_LIMIT", "250");
30837        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
30838        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
30839        let cfg = load_runtime_security_config(true);
30840        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
30841        assert!(cfg.trust_proxy);
30842        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
30843        std::env::remove_var("SLOC_API_KEYS");
30844        std::env::remove_var("SLOC_TRUST_PROXY");
30845        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30846        std::env::remove_var("SLOC_RATE_LIMIT");
30847        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
30848        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
30849    }
30850
30851    #[test]
30852    fn cors_layer_builds_both_modes() {
30853        let _ = build_cors_layer(true);
30854        let _ = build_cors_layer(false);
30855    }
30856
30857    #[test]
30858    fn primary_lan_ip_callable() {
30859        // May be Some or None depending on the host; both are valid.
30860        let _ = primary_lan_ip();
30861    }
30862
30863    #[test]
30864    fn safe_redirect_allows_relative_rejects_absolute() {
30865        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
30866        assert_eq!(safe_redirect("https://evil.example/x"), "/");
30867        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
30868        assert_eq!(default_redirect(), "/view-reports");
30869    }
30870
30871    #[test]
30872    fn tarball_size_caps_env_override() {
30873        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
30874        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
30875        let (c, d) = parse_tarball_size_caps();
30876        assert_eq!(c, 1024 * 1024);
30877        assert_eq!(d, 2 * 1024 * 1024);
30878        std::env::remove_var("SLOC_MAX_TARBALL_MB");
30879        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
30880        let (c2, _) = parse_tarball_size_caps();
30881        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
30882    }
30883
30884    #[test]
30885    fn upload_path_helpers() {
30886        let base = upload_base_dir();
30887        let staged = upload_staging_path("abc123");
30888        assert!(staged.starts_with(&base));
30889        assert!(
30890            is_upload_tmp_path(&staged),
30891            "staging path is an upload tmp path"
30892        );
30893        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
30894    }
30895
30896    #[test]
30897    fn git_clones_dir_env_override() {
30898        std::env::remove_var("SLOC_GIT_CLONES_DIR");
30899        let def = resolve_git_clones_dir(Path::new("/out"));
30900        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
30901        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
30902        assert_eq!(
30903            resolve_git_clones_dir(Path::new("/out")),
30904            PathBuf::from("/custom/clones")
30905        );
30906        std::env::remove_var("SLOC_GIT_CLONES_DIR");
30907    }
30908
30909    #[test]
30910    fn html_report_file_detection() {
30911        let dir = std::env::temp_dir().join("sloc_html_detect");
30912        let _ = std::fs::create_dir_all(&dir);
30913        let good = dir.join("report_x.html");
30914        std::fs::write(&good, "<html></html>").unwrap();
30915        let bad = dir.join("notes.txt");
30916        std::fs::write(&bad, "x").unwrap();
30917        assert!(is_html_report_file(&good));
30918        assert!(!is_html_report_file(&bad));
30919        assert!(find_html_report_in_dir(&dir).is_some());
30920        let _ = std::fs::remove_dir_all(&dir);
30921    }
30922
30923    #[test]
30924    fn multi_delta_class_and_format() {
30925        assert_eq!(multi_delta_class(5), "pos");
30926        assert_eq!(multi_delta_class(-5), "neg");
30927        assert_eq!(multi_delta_class(0), "zero");
30928        assert_eq!(multi_fmt_delta(3), "+3");
30929        assert_eq!(multi_fmt_delta(-3), "-3");
30930        assert_eq!(multi_fmt_delta(0), "0");
30931    }
30932
30933    #[test]
30934    fn git_clone_dest_sanitizes() {
30935        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
30936        assert!(dest.starts_with("/clones"));
30937        let name = dest.file_name().unwrap().to_str().unwrap();
30938        assert!(name
30939            .chars()
30940            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
30941    }
30942}