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, read_json, AnalysisRun, CleanupPolicy, CleanupPolicyStore,
72    FileChangeStatus, RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals,
73    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}
517
518/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
519#[derive(Clone)]
520enum AsyncRunState {
521    Running {
522        started_at: std::time::Instant,
523        cancel_token: Arc<std::sync::atomic::AtomicBool>,
524        phase: Arc<std::sync::Mutex<String>>,
525        files_done: Arc<std::sync::atomic::AtomicUsize>,
526        files_total: Arc<std::sync::atomic::AtomicUsize>,
527    },
528    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
529    Complete {
530        run_id: String,
531    },
532    Failed {
533        message: String,
534    },
535    Cancelled,
536}
537
538/// A saved scan configuration profile — stores the form parameters so users can
539/// re-run a favourite scan with one click.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541struct ScanProfile {
542    id: String,
543    name: String,
544    created_at: String,
545    /// The raw scan-form parameters serialized as JSON.
546    params: serde_json::Value,
547}
548
549#[derive(Debug, Clone, Default, Serialize, Deserialize)]
550struct ScanProfileStore {
551    profiles: Vec<ScanProfile>,
552}
553
554impl ScanProfileStore {
555    fn load(path: &std::path::Path) -> Self {
556        fs::read_to_string(path)
557            .ok()
558            .and_then(|s| serde_json::from_str(&s).ok())
559            .unwrap_or_default()
560    }
561
562    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
563        if let Some(parent) = path.parent() {
564            fs::create_dir_all(parent)?;
565        }
566        let json = serde_json::to_string_pretty(self)?;
567        fs::write(path, json)?;
568        Ok(())
569    }
570}
571
572#[derive(Clone)]
573pub(crate) struct AppState {
574    pub(crate) base_config: AppConfig,
575    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
576    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
577    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
578    pub(crate) registry_path: PathBuf,
579    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
580    pub(crate) server_mode: bool,
581    pub(crate) tls_enabled: bool,
582    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
583    pub(crate) rate_limiter: Arc<IpRateLimiter>,
584    pub(crate) trust_proxy: bool,
585    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
586    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
587    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
588    /// Directory where remote repositories are cloned for git-browser scans.
589    pub(crate) git_clones_dir: PathBuf,
590    /// Persisted list of webhook / poll schedules.
591    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
592    pub(crate) schedules_path: PathBuf,
593    /// Named scan profiles saved by the user via the web UI.
594    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
595    pub(crate) scan_profiles_path: PathBuf,
596    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
597    /// Persisted Confluence integration settings.
598    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
599    pub(crate) confluence_path: PathBuf,
600    /// Directories the user has pinned for auto-scanning of external reports.
601    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
602    pub(crate) watched_dirs_path: PathBuf,
603    /// Persisted auto-cleanup policy (age/count limits + interval).
604    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
605    pub(crate) cleanup_policy_path: PathBuf,
606    /// Handle for the running cleanup background task; replaced on policy change.
607    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
608}
609
610type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
611
612/// Parameters for the fire-and-forget HTML + PDF background task.
613
614#[derive(Clone, Debug)]
615pub(crate) struct RunArtifacts {
616    output_dir: PathBuf,
617    html_path: Option<PathBuf>,
618    pdf_path: Option<PathBuf>,
619    json_path: Option<PathBuf>,
620    csv_path: Option<PathBuf>,
621    xlsx_path: Option<PathBuf>,
622    scan_config_path: Option<PathBuf>,
623    report_title: String,
624    result_context: RunResultContext,
625}
626
627#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
628fn build_router(state: AppState) -> Router {
629    let protected = Router::new()
630        .route("/", get(splash))
631        .route("/scan-setup", get(scan_setup_handler))
632        .route("/scan", get(index))
633        .route("/analyze", post(analyze_handler))
634        .route("/preview", get(preview_handler))
635        .route("/api/suggest-coverage", get(api_suggest_coverage))
636        .route("/pick-directory", get(pick_directory_handler))
637        .route("/open-path", get(open_path_handler))
638        .route("/pick-file", get(pick_file_handler))
639        .route(
640            "/api/upload-directory",
641            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
642        )
643        .route(
644            "/api/upload-file",
645            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
646        )
647        .route(
648            "/api/upload-tarball",
649            post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
650        )
651        .route("/locate-report", post(locate_report_handler))
652        .route("/locate-reports-dir", post(locate_reports_dir_handler))
653        .route("/relocate-scan", post(relocate_scan_handler))
654        .route("/watched-dirs/add", post(add_watched_dir_handler))
655        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
656        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
657        .route("/view-reports", get(history_handler))
658        .route("/compare-scans", get(compare_select_handler))
659        .route("/compare", get(compare_handler))
660        .route("/images/{folder}/{file}", get(image_handler))
661        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
662        .route("/api/metrics/latest", get(api_metrics_latest_handler))
663        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
664        .route("/api/metrics/history", get(api_metrics_history_handler))
665        .route(
666            "/api/metrics/submodules",
667            get(api_metrics_submodules_handler),
668        )
669        .route("/api/ingest", post(api_ingest_handler))
670        .route("/api/project-history", get(project_history_handler))
671        .route("/trend-reports", get(trend_report_handler))
672        .route("/test-metrics", get(test_metrics_handler))
673        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
674        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
675        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
676        .route("/runs/result/{run_id}", get(async_run_result_handler))
677        .route("/embed/summary", get(embed_handler))
678        // ── Git browser ────────────────────────────────────────────────────────
679        .route("/git-browser", get(git_browser::git_browser_handler))
680        .route("/api/git/refs", get(git_browser::api_list_refs))
681        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
682        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
683        // ── Config export / import ─────────────────────────────────────────────
684        .route("/export-config", get(export_config_handler))
685        .route("/import-config", post(import_config_handler))
686        // ── Scan profiles ──────────────────────────────────────────────────────
687        .route("/api/scan-profiles", get(api_list_scan_profiles))
688        .route("/api/scan-profiles", post(api_save_scan_profile))
689        .route(
690            "/api/scan-profiles/{id}",
691            axum::routing::delete(api_delete_scan_profile),
692        )
693        // ── Integrations (webhooks + Confluence) ──────────────────────────────
694        .route("/integrations", get(integrations::integrations_handler))
695        .route(
696            "/webhook-setup",
697            get(|| async { axum::response::Redirect::permanent("/integrations") }),
698        )
699        .route(
700            "/confluence-setup",
701            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
702        )
703        .route("/api/schedules", get(git_webhook::api_list_schedules))
704        .route("/api/schedules", post(git_webhook::api_create_schedule))
705        .route(
706            "/api/schedules",
707            axum::routing::delete(git_webhook::api_delete_schedule),
708        )
709        .route(
710            "/api/confluence/config",
711            get(confluence::api_get_confluence_config),
712        )
713        .route(
714            "/api/confluence/config",
715            post(confluence::api_save_confluence_config),
716        )
717        .route(
718            "/api/confluence/test",
719            post(confluence::api_test_confluence),
720        )
721        .route(
722            "/api/confluence/post",
723            post(confluence::api_post_to_confluence),
724        )
725        .route(
726            "/api/confluence/wiki-markup",
727            get(confluence::api_wiki_markup),
728        )
729        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
730        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
731        .route(
732            "/api/runs/{run_id}",
733            axum::routing::delete(delete_run_handler),
734        )
735        .route("/api/runs/cleanup", post(cleanup_runs_handler))
736        // ── Auto-cleanup policy ────────────────────────────────────────────────
737        .route(
738            "/api/cleanup-policy",
739            get(api_get_cleanup_policy)
740                .post(api_save_cleanup_policy)
741                .delete(api_delete_cleanup_policy),
742        )
743        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
744        // ── REST API reference page ────────────────────────────────────────────
745        .route("/api-docs", get(api_docs_handler))
746        // ── Prometheus metrics — behind API-key auth ───────────────────────────
747        .route("/metrics", get(metrics_handler))
748        .route_layer(middleware::from_fn_with_state(
749            state.clone(),
750            auth::require_api_key,
751        ));
752
753    protected
754        .route("/healthz", get(healthz))
755        .route("/api/health", get(healthz))
756        .route("/api/version", get(api_version_handler))
757        .route("/api/openapi.yaml", get(openapi_yaml_handler))
758        .route("/badge/{metric}", get(badge_handler))
759        .route("/static/chart.js", get(chart_js_handler))
760        .route("/static/chart-report.js", get(report_chart_js_handler))
761        .route("/auth/login", get(auth::auth_login_get))
762        .route("/auth/login", post(auth::auth_login_post))
763        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
764        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
765        .route(
766            "/webhooks/github",
767            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
768        )
769        .route(
770            "/webhooks/gitlab",
771            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
772        )
773        .route(
774            "/webhooks/bitbucket",
775            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
776        )
777        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
778        .layer(middleware::from_fn_with_state(
779            state.clone(),
780            add_security_headers,
781        ))
782        .layer(build_cors_layer(state.server_mode))
783        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
784        .with_state(state)
785}
786
787/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
788pub fn make_test_router() -> Router {
789    // Suppress native OS dialogs (file pickers, open-path) during tests.
790    std::env::set_var("SLOC_HEADLESS", "1");
791    let tmp = std::env::temp_dir().join("sloc_test");
792    let state = AppState {
793        base_config: AppConfig::default(),
794        artifacts: Arc::new(Mutex::new(HashMap::new())),
795        async_runs: Arc::new(Mutex::new(HashMap::new())),
796        registry: Arc::new(Mutex::new(ScanRegistry::default())),
797        registry_path: tmp.join("registry.json"),
798        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
799        server_mode: false,
800        tls_enabled: false,
801        api_keys: Arc::new(vec![]),
802        rate_limiter: Arc::new(IpRateLimiter::new(
803            Duration::from_mins(1),
804            600,
805            10,
806            Duration::from_hours(1),
807        )),
808        trust_proxy: false,
809        trusted_proxy_ips: vec![],
810        git_clones_dir: tmp.join("git-clones"),
811        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
812        schedules_path: tmp.join("schedules.json"),
813        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
814        scan_profiles_path: tmp.join("scan_profiles.json"),
815        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
816        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
817        confluence_path: tmp.join("confluence_config.json"),
818        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
819        watched_dirs_path: tmp.join("watched_dirs.json"),
820        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
821        cleanup_policy_path: tmp.join("cleanup_policy.json"),
822        cleanup_task_handle: Arc::new(Mutex::new(None)),
823    };
824    build_router(state)
825}
826
827/// Test router with one API key pre-loaded. Used by auth integration tests.
828pub fn make_test_router_with_key(api_key: &str) -> Router {
829    let tmp = std::env::temp_dir().join("sloc_test_key");
830    let state = AppState {
831        base_config: AppConfig::default(),
832        artifacts: Arc::new(Mutex::new(HashMap::new())),
833        async_runs: Arc::new(Mutex::new(HashMap::new())),
834        registry: Arc::new(Mutex::new(ScanRegistry::default())),
835        registry_path: tmp.join("registry.json"),
836        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
837        server_mode: false,
838        tls_enabled: false,
839        api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
840        rate_limiter: Arc::new(IpRateLimiter::new(
841            Duration::from_mins(1),
842            600,
843            10,
844            Duration::from_hours(1),
845        )),
846        trust_proxy: false,
847        trusted_proxy_ips: vec![],
848        git_clones_dir: tmp.join("git-clones"),
849        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
850        schedules_path: tmp.join("schedules.json"),
851        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
852        scan_profiles_path: tmp.join("scan_profiles.json"),
853        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
854        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
855        confluence_path: tmp.join("confluence_config.json"),
856        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
857        watched_dirs_path: tmp.join("watched_dirs.json"),
858        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
859        cleanup_policy_path: tmp.join("cleanup_policy.json"),
860        cleanup_task_handle: Arc::new(Mutex::new(None)),
861    };
862    build_router(state)
863}
864
865struct RuntimeSecurityConfig {
866    api_keys: Vec<secrecy::SecretBox<String>>,
867    tls_cert: Option<String>,
868    tls_key: Option<String>,
869    tls_enabled: bool,
870    trust_proxy: bool,
871    trusted_proxy_ips: Vec<IpAddr>,
872    rate_limiter: Arc<IpRateLimiter>,
873}
874
875fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
876    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
877        .or_else(|_| std::env::var("SLOC_API_KEY"))
878        .unwrap_or_default()
879        .split(',')
880        .map(str::trim)
881        .filter(|s| !s.is_empty())
882        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
883        .collect();
884    if server_mode && api_keys.is_empty() {
885        println!(
886            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
887             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
888        );
889    }
890    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
891    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
892    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
893    if server_mode && !tls_enabled {
894        println!(
895            "WARNING: TLS is not configured. Traffic is cleartext. \
896             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
897             or terminate TLS at a reverse proxy (nginx, caddy)."
898        );
899    }
900    if server_mode {
901        println!(
902            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
903             to restrict cross-origin access (comma-separated)."
904        );
905    }
906    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
907    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
908        .unwrap_or_default()
909        .split(',')
910        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
911        .collect();
912    if trust_proxy {
913        if trusted_proxy_ips.is_empty() {
914            println!(
915                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
916                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
917                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
918            );
919        } else {
920            println!(
921                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
922                trusted_proxy_ips
923                    .iter()
924                    .map(std::string::ToString::to_string)
925                    .collect::<Vec<_>>()
926                    .join(", ")
927            );
928        }
929    } else if server_mode {
930        println!(
931            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
932             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
933             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
934             enable per-client rate limiting via X-Forwarded-For."
935        );
936    }
937    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
938        println!(
939            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
940             DISABLED for all git operations. Remove this variable before production use."
941        );
942    }
943    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
944        .ok()
945        .and_then(|v| v.parse::<u32>().ok())
946        .unwrap_or(10);
947    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
948        .ok()
949        .and_then(|v| v.parse::<u64>().ok())
950        .unwrap_or(3600);
951    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
952    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
953    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
954    let default_rpm: usize = if server_mode { 120 } else { 600 };
955    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
956        .ok()
957        .and_then(|v| v.parse::<usize>().ok())
958        .unwrap_or(default_rpm);
959    let rate_limiter = Arc::new(IpRateLimiter::new(
960        Duration::from_mins(1),
961        rate_limit_rpm,
962        auth_lockout_threshold,
963        Duration::from_secs(auth_lockout_secs),
964    ));
965    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
966    RuntimeSecurityConfig {
967        api_keys,
968        tls_cert,
969        tls_key,
970        tls_enabled,
971        trust_proxy,
972        trusted_proxy_ips,
973        rate_limiter,
974    }
975}
976
977/// # Errors
978///
979/// Returns an error if the server fails to bind to the configured address or
980/// if the TLS configuration cannot be loaded.
981///
982/// # Panics
983///
984/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
985#[allow(clippy::too_many_lines)]
986pub async fn serve(config: AppConfig) -> Result<()> {
987    let bind_address = config.web.bind_address.clone();
988    let server_mode = config.web.server_mode;
989    let output_root = resolve_output_root(None);
990    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
991    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
992        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
993    let mut registry = ScanRegistry::load(&registry_path);
994    registry.prune_stale();
995    let _ = registry.save(&registry_path);
996
997    let sec = load_runtime_security_config(server_mode);
998    spawn_upload_staging_cleanup();
999
1000    let git_clones_dir = resolve_git_clones_dir(&output_root);
1001    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1002        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1003    let schedules = ScheduleStore::load(&schedules_path);
1004    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1005        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1006    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1007    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1008        |_| output_root.join("confluence_config.json"),
1009        PathBuf::from,
1010    );
1011    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1012    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1013        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1014    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1015    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1016        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1017    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1018
1019    let state = AppState {
1020        base_config: config,
1021        artifacts: Arc::new(Mutex::new(HashMap::new())),
1022        async_runs: Arc::new(Mutex::new(HashMap::new())),
1023        registry: Arc::new(Mutex::new(registry)),
1024        registry_path,
1025        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1026        server_mode,
1027        tls_enabled: sec.tls_enabled,
1028        api_keys: Arc::new(sec.api_keys),
1029        rate_limiter: sec.rate_limiter,
1030        trust_proxy: sec.trust_proxy,
1031        trusted_proxy_ips: sec.trusted_proxy_ips,
1032        git_clones_dir,
1033        schedules: Arc::new(Mutex::new(schedules)),
1034        schedules_path,
1035        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1036        scan_profiles_path,
1037        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1038        confluence: Arc::new(Mutex::new(confluence)),
1039        confluence_path,
1040        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1041        watched_dirs_path,
1042        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1043        cleanup_policy_path,
1044        cleanup_task_handle: Arc::new(Mutex::new(None)),
1045    };
1046
1047    restart_poll_schedules(&state).await;
1048
1049    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1050    {
1051        let enabled = state
1052            .cleanup_policy
1053            .lock()
1054            .await
1055            .policy
1056            .as_ref()
1057            .is_some_and(|p| p.enabled);
1058        if enabled {
1059            let handle = spawn_cleanup_policy_task(state.clone());
1060            *state.cleanup_task_handle.lock().await = Some(handle);
1061        }
1062    }
1063
1064    let app = build_router(state.clone());
1065
1066    // Try the configured port first, then step up through a few alternatives.
1067    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1068    // kernel zombie (visible in netstat but owned by no living process).  Rather
1069    // than failing, we auto-select the next free port and tell the user.
1070    let preferred: SocketAddr = bind_address
1071        .parse()
1072        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1073    let (listener, addr) = {
1074        let candidates = (0u16..=9).map(|offset| {
1075            let mut a = preferred;
1076            a.set_port(preferred.port().saturating_add(offset));
1077            a
1078        });
1079        let mut found = None;
1080        for candidate in candidates {
1081            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1082                found = Some((l, candidate));
1083                break;
1084            }
1085        }
1086        found.ok_or_else(|| {
1087            anyhow::anyhow!(
1088                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1089                bind_address,
1090                preferred.port(),
1091                preferred.port().saturating_add(9)
1092            )
1093        })?
1094    };
1095    if addr != preferred {
1096        eprintln!(
1097            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1098             using {} instead.",
1099            preferred.port(),
1100            addr.port()
1101        );
1102    }
1103
1104    if sec.tls_enabled {
1105        let cert_path = sec
1106            .tls_cert
1107            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1108        let key_path = sec
1109            .tls_key
1110            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1111        let tls_config = build_tls_config(&cert_path, &key_path)
1112            .context("failed to load TLS certificate/key")?;
1113        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1114
1115        let url = format!("https://{addr}/");
1116        println!("OxideSLOC server running at {url} (TLS)");
1117        println!("Use Ctrl+C to stop.");
1118
1119        return serve_tls(listener, app, acceptor, server_mode).await;
1120    }
1121
1122    let url = format!("http://{addr}/");
1123    log_startup_url(&url, server_mode);
1124
1125    axum::serve(
1126        listener,
1127        app.into_make_service_with_connect_info::<SocketAddr>(),
1128    )
1129    .with_graceful_shutdown(shutdown_signal(server_mode))
1130    .await
1131    .context("web server terminated unexpectedly")
1132}
1133
1134/// Discover the primary non-loopback IPv4 address by asking the OS which
1135/// outbound interface it would use to reach a public address.  No packets are
1136/// sent — the UDP socket is only used to query the routing table.
1137fn primary_lan_ip() -> Option<String> {
1138    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1139    socket.connect("8.8.8.8:80").ok()?;
1140    let addr = socket.local_addr().ok()?;
1141    let ip = addr.ip();
1142    if ip.is_loopback() {
1143        return None;
1144    }
1145    Some(ip.to_string())
1146}
1147
1148/// Print the startup URL and, in local mode, open the browser and schedule it.
1149fn log_startup_url(url: &str, server_mode: bool) {
1150    if server_mode {
1151        println!("OxideSLOC server running at {url}");
1152        println!("Use Ctrl+C to stop.");
1153    } else {
1154        println!("OxideSLOC local web UI running at {url}");
1155        println!("Press Ctrl+C to stop the server.");
1156        let open_url = url.to_owned();
1157        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1158    }
1159}
1160
1161/// Open the given URL in the default system browser.
1162fn open_browser_tab(url: &str) {
1163    #[cfg(target_os = "windows")]
1164    let _ = std::process::Command::new("cmd")
1165        .args(["/c", "start", "", url])
1166        .stdout(Stdio::null())
1167        .stderr(Stdio::null())
1168        .spawn();
1169    #[cfg(target_os = "macos")]
1170    let _ = std::process::Command::new("open")
1171        .arg(url)
1172        .stdout(Stdio::null())
1173        .stderr(Stdio::null())
1174        .spawn();
1175    #[cfg(target_os = "linux")]
1176    let _ = std::process::Command::new("xdg-open")
1177        .arg(url)
1178        .stdout(Stdio::null())
1179        .stderr(Stdio::null())
1180        .spawn();
1181}
1182
1183/// Graceful-shutdown future: resolves on Ctrl-C.
1184async fn shutdown_signal(server_mode: bool) {
1185    if tokio::signal::ctrl_c().await.is_ok() {
1186        println!();
1187        if server_mode {
1188            println!("Shutting down OxideSLOC server...");
1189        } else {
1190            println!("Shutting down OxideSLOC local web UI...");
1191        }
1192        println!("Server stopped cleanly.");
1193    }
1194}
1195
1196/// Load a rustls `ServerConfig` from PEM certificate and key files.
1197fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1198    use rustls_pki_types::pem::PemObject;
1199    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1200
1201    let cert_bytes =
1202        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1203    let key_bytes =
1204        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1205
1206    let cert_chain: Vec<CertificateDer<'static>> =
1207        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1208            .collect::<std::result::Result<_, _>>()
1209            .context("failed to parse TLS certificates")?;
1210
1211    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1212        .context("failed to parse TLS private key")?;
1213
1214    rustls::ServerConfig::builder()
1215        .with_no_client_auth()
1216        .with_single_cert(cert_chain, key)
1217        .context("failed to build TLS server config")
1218}
1219
1220/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1221async fn serve_tls(
1222    listener: tokio::net::TcpListener,
1223    app: Router,
1224    acceptor: tokio_rustls::TlsAcceptor,
1225    server_mode: bool,
1226) -> Result<()> {
1227    use hyper_util::rt::{TokioExecutor, TokioIo};
1228    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1229    use hyper_util::service::TowerToHyperService;
1230    use tower::{Service, ServiceExt};
1231
1232    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1233
1234    loop {
1235        tokio::select! {
1236            biased;
1237            _ = tokio::signal::ctrl_c() => {
1238                println!();
1239                if server_mode {
1240                    println!("Shutting down OxideSLOC server...");
1241                } else {
1242                    println!("Shutting down OxideSLOC local web UI...");
1243                }
1244                println!("Server stopped cleanly.");
1245                return Ok(());
1246            }
1247            result = listener.accept() => {
1248                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1249                let acceptor = acceptor.clone();
1250                let mut factory = make_svc.clone();
1251
1252                tokio::spawn(async move {
1253                    let tls = match acceptor.accept(tcp).await {
1254                        Ok(s) => s,
1255                        Err(e) => {
1256                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1257                            return;
1258                        }
1259                    };
1260                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1261                        Ok(f) => match Service::call(f, peer_addr).await {
1262                            Ok(s) => s,
1263                            Err(_) => return,
1264                        },
1265                        Err(_) => return,
1266                    };
1267                    let io = TokioIo::new(tls);
1268                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1269                        .serve_connection(io, TowerToHyperService::new(svc))
1270                        .await
1271                    {
1272                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1273                    }
1274                });
1275            }
1276        }
1277    }
1278}
1279
1280// auth moved to auth.rs
1281
1282fn build_cors_layer(server_mode: bool) -> CorsLayer {
1283    if server_mode {
1284        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1285            .unwrap_or_default()
1286            .split(',')
1287            .filter(|s| !s.is_empty())
1288            .filter_map(|s| s.trim().parse().ok())
1289            .collect();
1290        if allowed.is_empty() {
1291            return CorsLayer::new();
1292        }
1293        CorsLayer::new()
1294            .allow_origin(AllowOrigin::list(allowed))
1295            .allow_methods(AllowMethods::list([
1296                axum::http::Method::GET,
1297                axum::http::Method::POST,
1298            ]))
1299            .allow_headers(AllowHeaders::list([
1300                axum::http::header::AUTHORIZATION,
1301                axum::http::header::CONTENT_TYPE,
1302            ]))
1303    } else {
1304        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1305            let s = origin.to_str().unwrap_or("");
1306            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1307        }))
1308    }
1309}
1310
1311async fn add_security_headers(
1312    State(state): State<AppState>,
1313    mut req: Request<Body>,
1314    next: Next,
1315) -> Response {
1316    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1317    req.extensions_mut().insert(CspNonce(nonce.clone()));
1318    let mut resp = next.run(req).await;
1319    let h = resp.headers_mut();
1320    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1321    h.insert(
1322        "X-Content-Type-Options",
1323        HeaderValue::from_static("nosniff"),
1324    );
1325    h.insert(
1326        "Referrer-Policy",
1327        HeaderValue::from_static("strict-origin-when-cross-origin"),
1328    );
1329    let csp = format!(
1330        "default-src 'self'; \
1331         style-src 'self' 'unsafe-inline'; \
1332         img-src 'self' data: blob:; \
1333         script-src 'self' 'nonce-{nonce}'; \
1334         font-src 'self' data:; \
1335         object-src 'none'; \
1336         frame-ancestors 'none'"
1337    );
1338    h.insert(
1339        "Content-Security-Policy",
1340        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1341            HeaderValue::from_static(
1342                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1343            )
1344        }),
1345    );
1346    h.insert(
1347        "X-Permitted-Cross-Domain-Policies",
1348        HeaderValue::from_static("none"),
1349    );
1350    h.insert(
1351        "Permissions-Policy",
1352        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1353    );
1354    h.insert(
1355        "Cross-Origin-Opener-Policy",
1356        HeaderValue::from_static("same-origin"),
1357    );
1358    h.insert(
1359        "Cross-Origin-Resource-Policy",
1360        HeaderValue::from_static("same-origin"),
1361    );
1362    if state.tls_enabled {
1363        h.insert(
1364            "Strict-Transport-Security",
1365            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1366        );
1367    }
1368    resp
1369}
1370
1371async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1372    let peer_ip = req
1373        .extensions()
1374        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1375        .map(|c| c.0.ip());
1376
1377    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1378    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1379    // header spoofing from direct connections.
1380    let ip = peer_ip
1381        .and_then(|peer| {
1382            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1383                req.headers()
1384                    .get("X-Forwarded-For")
1385                    .and_then(|v| v.to_str().ok())
1386                    .and_then(|s| s.split(',').next())
1387                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1388            } else {
1389                None
1390            }
1391        })
1392        .or(peer_ip)
1393        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1394
1395    if !state.rate_limiter.is_allowed(ip) {
1396        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1397            path = %req.uri().path(), "Rate limit exceeded");
1398        return (
1399            StatusCode::TOO_MANY_REQUESTS,
1400            [(header::RETRY_AFTER, "60")],
1401            "429 Too Many Requests\n",
1402        )
1403            .into_response();
1404    }
1405    next.run(req).await
1406}
1407
1408async fn splash(
1409    State(state): State<AppState>,
1410    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1411) -> impl IntoResponse {
1412    let lan_ip = if state.server_mode {
1413        primary_lan_ip()
1414    } else {
1415        None
1416    };
1417    let port = state
1418        .base_config
1419        .web
1420        .bind_address
1421        .rsplit(':')
1422        .next()
1423        .and_then(|p| p.parse::<u16>().ok())
1424        .unwrap_or(4317);
1425    let has_api_key = !state.api_keys.is_empty();
1426    let template = SplashTemplate {
1427        csp_nonce,
1428        server_mode: state.server_mode,
1429        lan_ip,
1430        port,
1431        version: env!("CARGO_PKG_VERSION"),
1432        has_api_key,
1433    };
1434    Html(
1435        template
1436            .render()
1437            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1438    )
1439}
1440
1441async fn index(
1442    State(state): State<AppState>,
1443    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1444    Query(query): Query<IndexQuery>,
1445) -> impl IntoResponse {
1446    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1447        let policy = query
1448            .mixed_line_policy
1449            .unwrap_or_else(|| "code_only".to_string());
1450        let behavior = query
1451            .binary_file_behavior
1452            .unwrap_or_else(|| "skip".to_string());
1453        let cfg = ScanConfig {
1454            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1455            path: query.path.unwrap_or_default(),
1456            include_globs: query.include_globs.unwrap_or_default(),
1457            exclude_globs: query.exclude_globs.unwrap_or_default(),
1458            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1459            mixed_line_policy: policy,
1460            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1461                != Some("off"),
1462            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1463            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1464            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1465                != Some("disabled"),
1466            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1467            binary_file_behavior: behavior,
1468            output_dir: query.output_dir.unwrap_or_default(),
1469            report_title: query.report_title.unwrap_or_default(),
1470        };
1471        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1472    } else {
1473        "{}".to_string()
1474    };
1475
1476    let git_repo = query.git_repo.unwrap_or_default();
1477    let git_ref = query.git_ref.unwrap_or_default();
1478
1479    let git_label = make_git_label(&git_repo, &git_ref);
1480    let git_output_dir = if git_label.is_empty() {
1481        String::new()
1482    } else {
1483        desktop_dir().join(&git_label).display().to_string()
1484    };
1485    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1486    let git_output_dir_json =
1487        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1488
1489    let template = IndexTemplate {
1490        version: env!("CARGO_PKG_VERSION"),
1491        prefill_json,
1492        csp_nonce,
1493        git_repo,
1494        git_ref,
1495        git_label_json,
1496        git_output_dir_json,
1497        server_mode: state.server_mode,
1498    };
1499
1500    Html(
1501        template
1502            .render()
1503            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1504    )
1505}
1506
1507async fn scan_setup_handler(
1508    State(state): State<AppState>,
1509    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1510) -> impl IntoResponse {
1511    let recent_scans_json = {
1512        let arr: Vec<serde_json::Value> = {
1513            let reg = state.registry.lock().await;
1514            reg.entries
1515                .iter()
1516                .rev()
1517                .take(6)
1518                .map(|e| {
1519                    let run_dir = e
1520                        .html_path
1521                        .as_ref()
1522                        .or(e.json_path.as_ref())
1523                        .and_then(|p| p.parent().map(PathBuf::from));
1524                    let config_val: Option<serde_json::Value> = run_dir
1525                        .and_then(|d| find_scan_config_in_dir(&d))
1526                        .and_then(|p| fs::read_to_string(&p).ok())
1527                        .and_then(|s| serde_json::from_str(&s).ok());
1528                    serde_json::json!({
1529                        "project_label": e.project_label,
1530                        "timestamp": fmt_la_time(e.timestamp_utc),
1531                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1532                        "config": config_val,
1533                    })
1534                })
1535                .collect()
1536        };
1537        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1538    };
1539
1540    let template = ScanSetupTemplate {
1541        version: env!("CARGO_PKG_VERSION"),
1542        recent_scans_json,
1543        csp_nonce,
1544    };
1545    Html(
1546        template
1547            .render()
1548            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1549    )
1550}
1551
1552async fn healthz() -> &'static str {
1553    "ok"
1554}
1555
1556async fn api_version_handler() -> impl IntoResponse {
1557    axum::Json(serde_json::json!({
1558        "name": "oxide-sloc",
1559        "version": env!("CARGO_PKG_VERSION"),
1560    }))
1561}
1562
1563// ── Prometheus metrics ────────────────────────────────────────────────────────
1564
1565fn prom_runs_total() -> &'static prometheus::IntCounter {
1566    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1567    COUNTER.get_or_init(|| {
1568        prometheus::register_int_counter!(
1569            "oxide_sloc_runs_total",
1570            "Total number of completed analysis runs"
1571        )
1572        .expect("failed to register oxide_sloc_runs_total counter")
1573    })
1574}
1575
1576async fn metrics_handler() -> impl IntoResponse {
1577    use prometheus::Encoder as _;
1578    let mut buf = Vec::new();
1579    let encoder = prometheus::TextEncoder::new();
1580    let _ = encoder.encode(&prometheus::gather(), &mut buf);
1581    (
1582        [(
1583            axum::http::header::CONTENT_TYPE,
1584            "text/plain; version=0.0.4; charset=utf-8",
1585        )],
1586        buf,
1587    )
1588}
1589
1590static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1591
1592async fn openapi_yaml_handler() -> impl IntoResponse {
1593    (
1594        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1595        OPENAPI_YAML,
1596    )
1597}
1598
1599async fn api_docs_handler(
1600    State(state): State<AppState>,
1601    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1602) -> impl IntoResponse {
1603    let has_api_key = !state.api_keys.is_empty();
1604    Html(
1605        ApiDocsTemplate {
1606            has_api_key,
1607            csp_nonce,
1608            version: env!("CARGO_PKG_VERSION"),
1609        }
1610        .render()
1611        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1612    )
1613}
1614
1615async fn chart_js_handler() -> impl IntoResponse {
1616    (
1617        [
1618            (
1619                header::CONTENT_TYPE,
1620                "application/javascript; charset=utf-8",
1621            ),
1622            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1623        ],
1624        CHART_JS,
1625    )
1626}
1627
1628async fn report_chart_js_handler() -> impl IntoResponse {
1629    (
1630        [
1631            (
1632                header::CONTENT_TYPE,
1633                "application/javascript; charset=utf-8",
1634            ),
1635            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1636        ],
1637        REPORT_CHART_JS,
1638    )
1639}
1640
1641#[derive(Debug, Deserialize)]
1642struct AnalyzeForm {
1643    path: String,
1644    git_repo: Option<String>,
1645    git_ref: Option<String>,
1646    mixed_line_policy: Option<MixedLinePolicy>,
1647    python_docstrings_as_comments: Option<String>,
1648    generated_file_detection: Option<String>,
1649    minified_file_detection: Option<String>,
1650    vendor_directory_detection: Option<String>,
1651    include_lockfiles: Option<String>,
1652    binary_file_behavior: Option<BinaryFileBehavior>,
1653    output_dir: Option<String>,
1654    report_title: Option<String>,
1655    report_header_footer: Option<String>,
1656    include_globs: Option<String>,
1657    exclude_globs: Option<String>,
1658    submodule_breakdown: Option<String>,
1659    coverage_file: Option<String>,
1660    continuation_line_policy: Option<ContinuationLinePolicy>,
1661    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1662    count_compiler_directives: Option<String>,
1663    style_col_threshold: Option<String>,
1664    style_analysis_enabled: Option<String>,
1665    style_score_threshold: Option<String>,
1666    style_lang_scope: Option<String>,
1667}
1668
1669#[allow(clippy::struct_excessive_bools)]
1670#[derive(Debug, Serialize, Deserialize, Clone)]
1671struct ScanConfig {
1672    oxide_sloc_version: String,
1673    path: String,
1674    include_globs: String,
1675    exclude_globs: String,
1676    submodule_breakdown: bool,
1677    mixed_line_policy: String,
1678    python_docstrings_as_comments: bool,
1679    generated_file_detection: bool,
1680    minified_file_detection: bool,
1681    vendor_directory_detection: bool,
1682    include_lockfiles: bool,
1683    binary_file_behavior: String,
1684    output_dir: String,
1685    report_title: String,
1686}
1687
1688#[derive(Debug, Deserialize, Default)]
1689struct IndexQuery {
1690    path: Option<String>,
1691    include_globs: Option<String>,
1692    exclude_globs: Option<String>,
1693    submodule_breakdown: Option<String>,
1694    mixed_line_policy: Option<String>,
1695    python_docstrings_as_comments: Option<String>,
1696    generated_file_detection: Option<String>,
1697    minified_file_detection: Option<String>,
1698    vendor_directory_detection: Option<String>,
1699    include_lockfiles: Option<String>,
1700    binary_file_behavior: Option<String>,
1701    output_dir: Option<String>,
1702    report_title: Option<String>,
1703    prefilled: Option<String>,
1704    git_repo: Option<String>,
1705    git_ref: Option<String>,
1706}
1707
1708#[derive(Debug, Deserialize)]
1709struct PreviewQuery {
1710    path: Option<String>,
1711    include_globs: Option<String>,
1712    exclude_globs: Option<String>,
1713}
1714
1715#[cfg(feature = "native-dialog")]
1716#[derive(Debug, Deserialize)]
1717struct PickDirectoryQuery {
1718    kind: Option<String>,
1719    current: Option<String>,
1720}
1721
1722#[cfg(not(feature = "native-dialog"))]
1723#[derive(Debug, Deserialize)]
1724struct PickDirectoryQuery {}
1725
1726#[derive(Debug, Deserialize, Default)]
1727struct ArtifactQuery {
1728    download: Option<String>,
1729}
1730
1731#[cfg(feature = "native-dialog")]
1732#[derive(Debug, Serialize)]
1733struct PickDirectoryResponse {
1734    selected_path: Option<String>,
1735    cancelled: bool,
1736}
1737
1738#[cfg(feature = "native-dialog")]
1739async fn pick_directory_handler(
1740    State(state): State<AppState>,
1741    Query(query): Query<PickDirectoryQuery>,
1742) -> Response {
1743    if state.server_mode {
1744        return StatusCode::NOT_FOUND.into_response();
1745    }
1746    // Return immediately without opening a dialog in headless / CI environments.
1747    if std::env::var("SLOC_HEADLESS").is_ok() {
1748        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1749            .into_response();
1750    }
1751
1752    let is_coverage = query.kind.as_deref() == Some("coverage");
1753    let title = match query.kind.as_deref() {
1754        Some("output") => "Select output directory",
1755        Some("reports") => "Select folder containing saved reports",
1756        Some("coverage") => "Select LCOV coverage file",
1757        _ => "Select project directory",
1758    }
1759    .to_owned();
1760    let current = query.current.clone();
1761
1762    let picked = tokio::task::spawn_blocking(move || {
1763        // Windows: attach to the foreground thread so the dialog inherits focus,
1764        // and kick off a watcher that flashes the dialog once it appears.
1765        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1766        let fg_tid = win_dialog_focus::attach_to_foreground();
1767        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1768        win_dialog_focus::flash_dialog_when_ready(title.clone());
1769
1770        let mut dialog = rfd::FileDialog::new().set_title(&title);
1771        if let Some(current) = current.as_deref() {
1772            let resolved = resolve_input_path(current);
1773            let seed = if resolved.is_dir() {
1774                Some(resolved)
1775            } else {
1776                resolved.parent().map(Path::to_path_buf)
1777            };
1778            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1779                dialog = dialog.set_directory(seed_dir);
1780            }
1781        }
1782        let result = if is_coverage {
1783            dialog
1784                .add_filter(
1785                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1786                    &["info", "lcov", "xml"],
1787                )
1788                .pick_file()
1789        } else {
1790            dialog.pick_folder()
1791        };
1792
1793        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1794        win_dialog_focus::detach_from_foreground(fg_tid);
1795
1796        result
1797    })
1798    .await
1799    .unwrap_or(None);
1800
1801    Json(PickDirectoryResponse {
1802        selected_path: picked.as_ref().map(|p| display_path(p)),
1803        cancelled: picked.is_none(),
1804    })
1805    .into_response()
1806}
1807
1808#[cfg(not(feature = "native-dialog"))]
1809async fn pick_directory_handler(
1810    State(_state): State<AppState>,
1811    Query(_query): Query<PickDirectoryQuery>,
1812) -> Response {
1813    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1814}
1815
1816#[cfg(feature = "native-dialog")]
1817async fn pick_file_handler(State(state): State<AppState>) -> Response {
1818    if state.server_mode {
1819        return StatusCode::NOT_FOUND.into_response();
1820    }
1821    if std::env::var("SLOC_HEADLESS").is_ok() {
1822        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1823            .into_response();
1824    }
1825    let picked = tokio::task::spawn_blocking(|| {
1826        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1827        let fg_tid = win_dialog_focus::attach_to_foreground();
1828        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1829        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1830
1831        let result = rfd::FileDialog::new()
1832            .set_title("Select HTML report")
1833            .add_filter("HTML report", &["html"])
1834            .pick_file();
1835
1836        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1837        win_dialog_focus::detach_from_foreground(fg_tid);
1838
1839        result
1840    })
1841    .await
1842    .unwrap_or(None);
1843    Json(PickDirectoryResponse {
1844        selected_path: picked.as_ref().map(|p| display_path(p)),
1845        cancelled: picked.is_none(),
1846    })
1847    .into_response()
1848}
1849
1850#[cfg(not(feature = "native-dialog"))]
1851async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1852    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1853}
1854
1855// ── Browser-upload handlers (server mode only) ────────────────────────────────
1856
1857/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
1858/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
1859fn is_upload_tmp_path(path: &Path) -> bool {
1860    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1861    path.starts_with(&upload_root)
1862}
1863
1864/// Returns true when `path` is the built-in sample or test-fixture directory.
1865/// These paths ship with the server binary and are always safe to scan/preview.
1866fn is_sample_path(path: &Path) -> bool {
1867    let root = workspace_root();
1868    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1869}
1870
1871/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
1872fn upload_base_dir() -> PathBuf {
1873    std::env::temp_dir().join("oxide-sloc-uploads")
1874}
1875
1876/// Returns the staging path for a given upload id inside the base dir.
1877fn upload_staging_path(id: &str) -> PathBuf {
1878    upload_base_dir().join(id)
1879}
1880
1881/// Validate basic field constraints on a directory-upload request.
1882/// Returns an error `Response` if the request should be rejected immediately.
1883#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1884fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1885    const MAX_FILES: usize = 50_000;
1886    if body.files.is_empty() {
1887        return Err((
1888            StatusCode::BAD_REQUEST,
1889            Json(serde_json::json!({"error": "No files received"})),
1890        )
1891            .into_response());
1892    }
1893    if body.files.len() > MAX_FILES {
1894        return Err((
1895            StatusCode::PAYLOAD_TOO_LARGE,
1896            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1897        )
1898            .into_response());
1899    }
1900    Ok(())
1901}
1902
1903/// Resolve or create the staging directory for a directory upload.
1904/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
1905fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1906    match id {
1907        Some(id)
1908            if !id.is_empty()
1909                && id.len() <= 36
1910                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1911        {
1912            (id.to_string(), upload_staging_path(id))
1913        }
1914        _ => {
1915            let new_id = uuid::Uuid::new_v4().to_string();
1916            let staging = upload_staging_path(&new_id);
1917            (new_id, staging)
1918        }
1919    }
1920}
1921
1922/// Decode, size-check, and write one uploaded file entry into `staging`.
1923/// Returns `Ok(())` whether the file was written or skipped (bad base64).
1924/// Returns `Err(Response)` for fatal errors; the caller is responsible for
1925/// cleaning up `staging` before propagating the error.
1926#[allow(clippy::result_large_err)]
1927async fn stage_decoded_entry(
1928    entry: &UploadedFile,
1929    staging: &Path,
1930    total_bytes: &mut usize,
1931    project_root: &mut Option<PathBuf>,
1932) -> Result<(), Response> {
1933    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1934
1935    let Ok(data) = base64::Engine::decode(
1936        &base64::engine::general_purpose::STANDARD,
1937        entry.content.as_bytes(),
1938    ) else {
1939        return Ok(());
1940    };
1941
1942    *total_bytes += data.len();
1943    if *total_bytes > MAX_TOTAL_BYTES {
1944        return Err((
1945            StatusCode::PAYLOAD_TOO_LARGE,
1946            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1947        )
1948            .into_response());
1949    }
1950
1951    let rel = std::path::Path::new(&entry.path);
1952    if project_root.is_none() {
1953        if let Some(first) = rel.components().next() {
1954            *project_root = Some(staging.join(first.as_os_str()));
1955        }
1956    }
1957
1958    let dest = staging.join(rel);
1959    if let Some(parent) = dest.parent() {
1960        if tokio::fs::create_dir_all(parent).await.is_err() {
1961            return Err((
1962                StatusCode::INTERNAL_SERVER_ERROR,
1963                Json(serde_json::json!({"error": "Failed to create directory structure"})),
1964            )
1965                .into_response());
1966        }
1967    }
1968
1969    if tokio::fs::write(&dest, &data).await.is_err() {
1970        return Err((
1971            StatusCode::INTERNAL_SERVER_ERROR,
1972            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1973        )
1974            .into_response());
1975    }
1976
1977    Ok(())
1978}
1979
1980/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
1981/// and path-traversal guard. Returns `(file_count, project_root)` on success or
1982/// an error `Response` on failure (staging dir is cleaned up before returning).
1983async fn write_upload_files(
1984    files: &[UploadedFile],
1985    staging: &Path,
1986    upload_id: &str,
1987) -> Result<(usize, Option<PathBuf>), Response> {
1988    let mut total_bytes: usize = 0;
1989    let mut project_root: Option<PathBuf> = None;
1990    let mut traversal_attempts: usize = 0;
1991
1992    for entry in files {
1993        let rel = std::path::Path::new(&entry.path);
1994        if rel
1995            .components()
1996            .any(|c| matches!(c, std::path::Component::ParentDir))
1997        {
1998            traversal_attempts += 1;
1999            if traversal_attempts >= 5 {
2000                let _ = tokio::fs::remove_dir_all(staging).await;
2001                tracing::warn!(
2002                    event = "upload_path_traversal",
2003                    upload_id = %upload_id,
2004                    "Upload rejected: repeated path traversal attempts detected"
2005                );
2006                return Err((
2007                    StatusCode::BAD_REQUEST,
2008                    Json(serde_json::json!({"error": "Upload rejected"})),
2009                )
2010                    .into_response());
2011            }
2012            continue;
2013        }
2014
2015        if let Err(resp) =
2016            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2017        {
2018            let _ = tokio::fs::remove_dir_all(staging).await;
2019            return Err(resp);
2020        }
2021    }
2022
2023    Ok((files.len(), project_root))
2024}
2025
2026/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2027/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2028fn parse_tarball_size_caps() -> (u64, u64) {
2029    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2030        .ok()
2031        .and_then(|v| v.parse().ok())
2032        .unwrap_or(2048_u64)
2033        * 1024
2034        * 1024;
2035    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2036        .ok()
2037        .and_then(|v| v.parse().ok())
2038        .unwrap_or(10_240_u64)
2039        * 1024
2040        * 1024;
2041    (compressed, decompressed)
2042}
2043
2044/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2045/// Returns the number of compressed bytes written, or an error `Response`.
2046/// Cleans up `dest_path` on error.
2047#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2048async fn stream_body_to_file(
2049    body: axum::body::Body,
2050    dest_path: &Path,
2051    max_bytes: u64,
2052) -> Result<u64, Response> {
2053    use http_body_util::BodyExt as _;
2054    use tokio::io::AsyncWriteExt as _;
2055
2056    let mut file = match tokio::fs::File::create(dest_path).await {
2057        Ok(f) => f,
2058        Err(e) => {
2059            tracing::error!(
2060                event = "upload_io_error",
2061                "failed to create tarball temp file: {e}"
2062            );
2063            return Err((
2064                StatusCode::INTERNAL_SERVER_ERROR,
2065                Json(serde_json::json!({"error": "Upload initialization failed"})),
2066            )
2067                .into_response());
2068        }
2069    };
2070
2071    let mut body = body;
2072    let mut written: u64 = 0;
2073    loop {
2074        match body.frame().await {
2075            None => break,
2076            Some(Err(e)) => {
2077                let _ = tokio::fs::remove_file(dest_path).await;
2078                return Err((
2079                    StatusCode::BAD_REQUEST,
2080                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2081                )
2082                    .into_response());
2083            }
2084            Some(Ok(frame)) => {
2085                if let Ok(data) = frame.into_data() {
2086                    written += data.len() as u64;
2087                    if written > max_bytes {
2088                        let _ = tokio::fs::remove_file(dest_path).await;
2089                        return Err((
2090                            StatusCode::PAYLOAD_TOO_LARGE,
2091                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2092                        )
2093                            .into_response());
2094                    }
2095                    if let Err(e) = file.write_all(&data).await {
2096                        let _ = tokio::fs::remove_file(dest_path).await;
2097                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2098                        return Err((
2099                            StatusCode::INTERNAL_SERVER_ERROR,
2100                            Json(serde_json::json!({"error": "Upload write failed"})),
2101                        )
2102                            .into_response());
2103                    }
2104                }
2105            }
2106        }
2107    }
2108    drop(file);
2109    Ok(written)
2110}
2111
2112/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2113/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2114/// on failure (staging dir is cleaned up before returning).
2115#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2116async fn extract_tarball_to_staging(
2117    tarball_path: &Path,
2118    staging: &Path,
2119    max_decompressed_bytes: u64,
2120) -> Result<(), Response> {
2121    let staging_clone = staging.to_path_buf();
2122    let tarball_clone = tarball_path.to_path_buf();
2123    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2124        let file = std::fs::File::open(&tarball_clone)?;
2125        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2126        let limited = SizeLimitReader {
2127            inner: gz,
2128            remaining: max_decompressed_bytes,
2129        };
2130        let mut archive = tar::Archive::new(limited);
2131        archive.set_overwrite(true);
2132        archive.set_preserve_permissions(false);
2133        std::fs::create_dir_all(&staging_clone)?;
2134        archive.unpack(&staging_clone)?;
2135        Ok(())
2136    })
2137    .await;
2138    let _ = tokio::fs::remove_file(tarball_path).await;
2139
2140    match extract_result {
2141        Ok(Ok(())) => Ok(()),
2142        Ok(Err(e)) => {
2143            let _ = tokio::fs::remove_dir_all(staging).await;
2144            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2145            tracing::warn!(
2146                event = "upload_extract_error",
2147                "tarball extraction failed: {e:#}"
2148            );
2149            let (status, msg) = if is_size_limit {
2150                (
2151                    StatusCode::PAYLOAD_TOO_LARGE,
2152                    "Archive exceeds the decompressed size limit",
2153                )
2154            } else {
2155                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2156            };
2157            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2158        }
2159        Err(e) => {
2160            let _ = tokio::fs::remove_dir_all(staging).await;
2161            tracing::error!(
2162                event = "upload_extract_panic",
2163                "tarball extraction task panicked: {e}"
2164            );
2165            Err((
2166                StatusCode::INTERNAL_SERVER_ERROR,
2167                Json(serde_json::json!({"error": "Archive extraction failed"})),
2168            )
2169                .into_response())
2170        }
2171    }
2172}
2173
2174/// If `staging` contains exactly one top-level directory, return its path
2175/// (the common case when the archive was created with `webkitRelativePath`).
2176/// Otherwise return `None`.
2177async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2178    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2179    let first = entries.next_entry().await.ok()??;
2180    if !first.path().is_dir() {
2181        return None;
2182    }
2183    if entries.next_entry().await.unwrap_or(None).is_some() {
2184        return None;
2185    }
2186    Some(first.path())
2187}
2188
2189/// Request body for `POST /api/upload-directory`.
2190///
2191/// Each entry carries a relative path (identical to the browser's
2192/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2193/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2194/// avoids pulling in a `multipart` library that is not in the vendor archive.
2195#[derive(Deserialize)]
2196struct UploadDirRequest {
2197    files: Vec<UploadedFile>,
2198    /// If provided, append this batch to an existing upload session instead of
2199    /// creating a new staging directory. Must be a plain UUID (no path separators).
2200    upload_id: Option<String>,
2201}
2202
2203#[derive(Deserialize)]
2204struct UploadedFile {
2205    /// `webkitRelativePath` value from the browser File object.
2206    path: String,
2207    /// Raw file bytes encoded as standard base64.
2208    content: String,
2209}
2210
2211/// POST /api/upload-directory
2212///
2213/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2214/// Saves all files to a temp staging directory preserving their relative paths,
2215/// then returns the server-side root directory path so the caller can populate
2216/// the scan-path field and run a normal analysis.
2217///
2218/// Only available in server mode; returns 404 in local mode (use the native
2219/// rfd dialog instead).
2220async fn upload_directory_handler(
2221    State(state): State<AppState>,
2222    Json(body): Json<UploadDirRequest>,
2223) -> Response {
2224    if !state.server_mode {
2225        return StatusCode::NOT_FOUND.into_response();
2226    }
2227    if let Err(resp) = validate_upload_dir_request(&body) {
2228        return resp;
2229    }
2230    // Reuse an existing staging dir when the client sends a continuation batch,
2231    // otherwise create a fresh one. Validate the id to prevent path traversal.
2232    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2233    match write_upload_files(&body.files, &staging, &upload_id).await {
2234        Ok((file_count, project_root)) => {
2235            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2236            Json(serde_json::json!({
2237                "tmp_path": scan_root.to_string_lossy(),
2238                "file_count": file_count,
2239                "upload_id": upload_id.clone()
2240            }))
2241            .into_response()
2242        }
2243        Err(resp) => resp,
2244    }
2245}
2246
2247/// Request body for `POST /api/upload-file`.
2248#[derive(Deserialize)]
2249struct UploadFileRequest {
2250    /// Original filename (used only to preserve the extension).
2251    filename: String,
2252    /// File bytes encoded as standard base64.
2253    content: String,
2254}
2255
2256/// POST /api/upload-file
2257///
2258/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2259/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2260/// Only available in server mode.
2261async fn upload_file_handler(
2262    State(state): State<AppState>,
2263    Json(body): Json<UploadFileRequest>,
2264) -> Response {
2265    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2266
2267    if !state.server_mode {
2268        return StatusCode::NOT_FOUND.into_response();
2269    }
2270
2271    let Ok(data) = base64::Engine::decode(
2272        &base64::engine::general_purpose::STANDARD,
2273        body.content.as_bytes(),
2274    ) else {
2275        return (
2276            StatusCode::BAD_REQUEST,
2277            Json(serde_json::json!({"error": "Invalid base64 content"})),
2278        )
2279            .into_response();
2280    };
2281
2282    if data.len() > MAX_FILE_BYTES {
2283        return (
2284            StatusCode::PAYLOAD_TOO_LARGE,
2285            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2286        )
2287            .into_response();
2288    }
2289
2290    // Sanitise: strip any directory component from the filename.
2291    let filename = std::path::Path::new(&body.filename)
2292        .file_name()
2293        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2294
2295    let upload_id = uuid::Uuid::new_v4();
2296    let staging = std::env::temp_dir()
2297        .join("oxide-sloc-uploads")
2298        .join(upload_id.to_string());
2299
2300    if tokio::fs::create_dir_all(&staging).await.is_err() {
2301        return (
2302            StatusCode::INTERNAL_SERVER_ERROR,
2303            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2304        )
2305            .into_response();
2306    }
2307
2308    let dest = staging.join(&filename);
2309    if tokio::fs::write(&dest, &data).await.is_err() {
2310        let _ = tokio::fs::remove_dir_all(&staging).await;
2311        return (
2312            StatusCode::INTERNAL_SERVER_ERROR,
2313            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2314        )
2315            .into_response();
2316    }
2317
2318    Json(serde_json::json!({
2319        "tmp_path": dest.to_string_lossy(),
2320        "upload_id": upload_id.to_string()
2321    }))
2322    .into_response()
2323}
2324
2325/// POST /api/upload-tarball
2326///
2327/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2328/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2329/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2330/// project root. The two size fields power the "Original / Compressed project size" display in the
2331/// web UI.
2332///
2333/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
2334/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
2335/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
2336/// bounded regardless of project size.
2337/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2338/// decompressed. Wraps any `std::io::Read` source.
2339struct SizeLimitReader<R> {
2340    inner: R,
2341    remaining: u64,
2342}
2343impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2344    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2345        if self.remaining == 0 {
2346            return Err(std::io::Error::other("decompressed size limit exceeded"));
2347        }
2348        let n = self.inner.read(buf)?;
2349        self.remaining = self.remaining.saturating_sub(n as u64);
2350        Ok(n)
2351    }
2352}
2353
2354async fn upload_tarball_handler(
2355    State(state): State<AppState>,
2356    request: axum::extract::Request,
2357) -> Response {
2358    if !state.server_mode {
2359        return StatusCode::NOT_FOUND.into_response();
2360    }
2361
2362    let upload_id = uuid::Uuid::new_v4().to_string();
2363    let upload_base = upload_base_dir();
2364    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2365    let staging = upload_staging_path(&upload_id);
2366    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2367
2368    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2369        tracing::error!(
2370            event = "upload_io_error",
2371            "failed to create upload base dir: {e}"
2372        );
2373        return (
2374            StatusCode::INTERNAL_SERVER_ERROR,
2375            Json(serde_json::json!({"error": "Upload initialization failed"})),
2376        )
2377            .into_response();
2378    }
2379
2380    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2381    let compressed_bytes =
2382        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2383            Ok(n) => n,
2384            Err(resp) => return resp,
2385        };
2386
2387    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2388    if let Err(resp) =
2389        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2390    {
2391        return resp;
2392    }
2393
2394    // ── 3. Find the project root inside the staging dir ───────────────────────
2395    // If the tar contained a single top-level directory (the common case when the
2396    // browser uses `webkitRelativePath`), return that as the scan root so the path
2397    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2398    let scan_root = find_single_top_dir(&staging)
2399        .await
2400        .unwrap_or_else(|| staging.clone());
2401
2402    // Compute original (uncompressed) size of the extracted tree.
2403    let original_bytes = tokio::task::spawn_blocking({
2404        let p = scan_root.clone();
2405        move || dir_size_bytes(&p)
2406    })
2407    .await
2408    .unwrap_or(0);
2409
2410    Json(serde_json::json!({
2411        "tmp_path": scan_root.to_string_lossy(),
2412        "upload_id": upload_id,
2413        "compressed_bytes": compressed_bytes,
2414        "original_bytes": original_bytes,
2415    }))
2416    .into_response()
2417}
2418
2419#[derive(Deserialize)]
2420struct LocateReportForm {
2421    file_path: String,
2422    #[serde(default)]
2423    redirect_url: Option<String>,
2424    #[serde(default)]
2425    expected_run_id: Option<String>,
2426}
2427
2428/// Render a view-reports error page and return it as a `Response`.
2429fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2430    let html = ErrorTemplate {
2431        message: message.into(),
2432        last_report_url: Some("/view-reports".to_string()),
2433        last_report_label: Some("View Reports".to_string()),
2434        run_id: None,
2435        error_code: None,
2436        csp_nonce: csp_nonce.to_owned(),
2437        version: env!("CARGO_PKG_VERSION"),
2438    }
2439    .render()
2440    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2441    Html(html).into_response()
2442}
2443
2444/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
2445fn registry_entry_from_run(
2446    run: &AnalysisRun,
2447    json_path: PathBuf,
2448    html_path: PathBuf,
2449) -> RegistryEntry {
2450    let project_label = run.input_roots.first().map_or_else(
2451        || "Unknown Project".to_string(),
2452        |r| sanitize_project_label(r),
2453    );
2454    RegistryEntry {
2455        run_id: run.tool.run_id.clone(),
2456        timestamp_utc: run.tool.timestamp_utc,
2457        project_label,
2458        input_roots: run.input_roots.clone(),
2459        json_path: Some(json_path),
2460        html_path: Some(html_path),
2461        pdf_path: None,
2462        summary: ScanSummarySnapshot {
2463            files_analyzed: run.summary_totals.files_analyzed,
2464            files_skipped: run.summary_totals.files_skipped,
2465            total_physical_lines: run.summary_totals.total_physical_lines,
2466            code_lines: run.summary_totals.code_lines,
2467            comment_lines: run.summary_totals.comment_lines,
2468            blank_lines: run.summary_totals.blank_lines,
2469            functions: run.summary_totals.functions,
2470            classes: run.summary_totals.classes,
2471            variables: run.summary_totals.variables,
2472            imports: run.summary_totals.imports,
2473            test_count: run.summary_totals.test_count,
2474            coverage_lines_found: run.summary_totals.coverage_lines_found,
2475            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2476            coverage_functions_found: run.summary_totals.coverage_functions_found,
2477            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2478            coverage_branches_found: run.summary_totals.coverage_branches_found,
2479            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2480        },
2481        csv_path: None,
2482        xlsx_path: None,
2483        git_branch: None,
2484        git_commit: None,
2485        git_author: None,
2486        git_tags: None,
2487        git_nearest_tag: None,
2488        git_commit_date: None,
2489    }
2490}
2491
2492/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
2493/// immediately without requiring a server restart.
2494pub(crate) async fn register_artifacts_in_registry(
2495    state: &AppState,
2496    label: &str,
2497    run: &AnalysisRun,
2498    artifacts: &RunArtifacts,
2499) {
2500    let Some(json_path) = artifacts.json_path.clone() else {
2501        return;
2502    };
2503    let Some(html_path) = artifacts.html_path.clone() else {
2504        return;
2505    };
2506    let mut entry = registry_entry_from_run(run, json_path, html_path);
2507    entry.project_label = label.to_owned();
2508    let mut reg = state.registry.lock().await;
2509    reg.add_entry(entry);
2510    let _ = reg.save(&state.registry_path);
2511}
2512
2513fn is_html_report_file(p: &Path) -> bool {
2514    p.is_file()
2515        && p.extension()
2516            .and_then(|x| x.to_str())
2517            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2518        && p.file_name()
2519            .and_then(|n| n.to_str())
2520            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2521}
2522
2523fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2524    fs::read_dir(dir)
2525        .ok()?
2526        .flatten()
2527        .map(|e| e.path())
2528        .find(|p| is_html_report_file(p))
2529}
2530
2531fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2532    if let Some(f) = find_html_report_in_dir(dir) {
2533        return Some(f);
2534    }
2535    if let Ok(rd) = fs::read_dir(dir) {
2536        for entry in rd.flatten() {
2537            let sub = entry.path();
2538            if sub.is_dir() {
2539                if let Some(f) = find_html_report_in_dir(&sub) {
2540                    return Some(f);
2541                }
2542            }
2543        }
2544    }
2545    None
2546}
2547
2548/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
2549/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
2550///
2551/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
2552#[allow(clippy::result_large_err)]
2553fn validate_locate_request(
2554    state: &AppState,
2555    file_path: &str,
2556    csp_nonce: &str,
2557) -> Result<(PathBuf, PathBuf), Response> {
2558    let raw = PathBuf::from(file_path);
2559
2560    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
2561    let html_path = if raw.is_dir() {
2562        let found = find_html_report_in_tree(&raw);
2563        match found {
2564            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2565            None => {
2566                return Err(locate_report_error(
2567                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
2568                     the folder that contains your scan output (result_*.html or report_*.html).",
2569                    csp_nonce,
2570                ));
2571            }
2572        }
2573    } else {
2574        let file_ext = raw
2575            .extension()
2576            .and_then(|e| e.to_str())
2577            .unwrap_or("")
2578            .to_ascii_lowercase();
2579        if file_ext != "html" {
2580            return Err(locate_report_error(
2581                "Please select the scan output folder, or an .html report file directly.",
2582                csp_nonce,
2583            ));
2584        }
2585        match fs::canonicalize(&raw) {
2586            Ok(p) => strip_unc_prefix(p),
2587            Err(_) => {
2588                return Err(locate_report_error(
2589                    "Report file not found or path is invalid.",
2590                    csp_nonce,
2591                ));
2592            }
2593        }
2594    };
2595
2596    if state.server_mode {
2597        let output_root = resolve_output_root(None);
2598        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2599        if !html_path.starts_with(&canonical_root) {
2600            return Err(locate_report_error(
2601                "Report file must be within the configured output directory.",
2602                csp_nonce,
2603            ));
2604        }
2605    }
2606    let parent = match html_path.parent() {
2607        Some(p) => p.to_path_buf(),
2608        None => {
2609            return Err(locate_report_error(
2610                "Report file has no parent directory.",
2611                csp_nonce,
2612            ));
2613        }
2614    };
2615    Ok((html_path, parent))
2616}
2617
2618/// JSON-or-HTML error for `locate_report_handler` error paths.
2619fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2620    if want_json {
2621        (
2622            StatusCode::UNPROCESSABLE_ENTITY,
2623            axum::Json(serde_json::json!({"ok": false, "message": msg})),
2624        )
2625            .into_response()
2626    } else {
2627        locate_report_error(msg, csp_nonce)
2628    }
2629}
2630
2631/// JSON-or-redirect success for locate/relocate handler success paths.
2632fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2633    if want_json {
2634        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2635    } else {
2636        axum::response::Redirect::to(redirect).into_response()
2637    }
2638}
2639
2640/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
2641/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
2642fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2643    for jpath in candidates {
2644        if let Ok(run) = read_json(jpath) {
2645            if expected.is_empty() || run.tool.run_id == expected {
2646                return Some((jpath.clone(), run.tool.run_id));
2647            }
2648        }
2649    }
2650    None
2651}
2652
2653fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2654    html_path
2655        .parent()
2656        .and_then(|p| p.parent())
2657        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2658}
2659
2660fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2661    let mut hits = collect_result_json_candidates(scan_root);
2662    if hits.is_empty() {
2663        hits = collect_result_json_candidates(parent);
2664    }
2665    hits.sort();
2666    hits
2667}
2668
2669#[allow(clippy::too_many_lines)]
2670async fn locate_report_handler(
2671    State(state): State<AppState>,
2672    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2673    headers: axum::http::HeaderMap,
2674    Form(form): Form<LocateReportForm>,
2675) -> impl IntoResponse {
2676    let want_json = headers
2677        .get(axum::http::header::ACCEPT)
2678        .and_then(|v| v.to_str().ok())
2679        .is_some_and(|v| v.contains("application/json"));
2680
2681    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2682        Ok(v) => v,
2683        Err(resp) => {
2684            if want_json {
2685                return locate_handler_err(
2686                    true,
2687                    "No HTML report file found in the selected folder. \
2688                     Make sure you selected the folder that contains your \
2689                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
2690                        .to_string(),
2691                    &csp_nonce,
2692                );
2693            }
2694            return resp;
2695        }
2696    };
2697
2698    // Search for result_*.json in the HTML's parent and also its grandparent (handles
2699    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
2700    let scan_root_owned = resolve_scan_root(&html_path, &parent);
2701    let scan_root: &Path = &scan_root_owned;
2702    let json_candidates = gather_json_candidates(scan_root, &parent);
2703
2704    // If the expected_run_id was provided, find a JSON that matches it exactly.
2705    let expected_run_id = form
2706        .expected_run_id
2707        .as_deref()
2708        .unwrap_or("")
2709        .trim()
2710        .to_string();
2711
2712    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2713
2714    // If we have candidates but none matched the expected run_id, surface a clear error.
2715    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2716        let actual = json_candidates
2717            .iter()
2718            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2719            .unwrap_or_else(|| "unknown".to_string());
2720        return locate_handler_err(
2721            want_json,
2722            format!(
2723                "This folder contains a different scan.\n\n\
2724                 Expected run ID : {expected_run_id}\n\
2725                 Found run ID    : {actual}\n\n\
2726                 Please select the folder that contains the correct scan output."
2727            ),
2728            &csp_nonce,
2729        );
2730    }
2731
2732    let safe_redirect = form
2733        .redirect_url
2734        .as_deref()
2735        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2736        .unwrap_or("/view-reports?linked=1")
2737        .to_string();
2738
2739    let mut reg = state.registry.lock().await;
2740
2741    if let Some((json_path, run_id)) = matched_json {
2742        // Match by run_id in the registry (works even after files are moved).
2743        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2744            entry.html_path = Some(html_path);
2745            entry.json_path = Some(json_path);
2746            let _ = reg.save(&state.registry_path);
2747            drop(reg);
2748            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
2749            state.artifacts.lock().await.remove(&run_id);
2750            return redirect_or_json_ok(want_json, &safe_redirect);
2751        }
2752        // No existing entry — build one from the JSON.
2753        match read_json(&json_path) {
2754            Ok(run) => {
2755                let entry = registry_entry_from_run(&run, json_path, html_path);
2756                reg.add_entry(entry);
2757                let _ = reg.save(&state.registry_path);
2758                drop(reg);
2759                state.artifacts.lock().await.remove(&run_id);
2760                return redirect_or_json_ok(want_json, &safe_redirect);
2761            }
2762            Err(e) => {
2763                drop(reg);
2764                return locate_handler_err(
2765                    want_json,
2766                    format!(
2767                        "Found the scan folder but could not parse the result JSON.\n\n\
2768                         The file may have been saved by an older version of OxideSLOC. \
2769                         Re-running the analysis will create a fresh, compatible record.\n\n\
2770                         Error: {e}"
2771                    ),
2772                    &csp_nonce,
2773                );
2774            }
2775        }
2776    }
2777
2778    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
2779    if let Some(entry) = reg
2780        .entries
2781        .iter_mut()
2782        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2783    {
2784        entry.html_path = Some(html_path.clone());
2785        let _ = reg.save(&state.registry_path);
2786        drop(reg);
2787        state.artifacts.lock().await.remove(&expected_run_id);
2788        return redirect_or_json_ok(want_json, &safe_redirect);
2789    }
2790
2791    drop(reg);
2792    let hint = if state.server_mode {
2793        String::new()
2794    } else {
2795        format!(
2796            "\n\nSearched folder : {}\nHTML found      : {}",
2797            scan_root.display(),
2798            html_path.display()
2799        )
2800    };
2801    locate_handler_err(
2802        want_json,
2803        format!(
2804            "Could not link this report.\n\n\
2805             No result_*.json was found in the selected folder. \
2806             Make sure you selected the top-level scan output folder \
2807             (the one that contains html/, json/, pdf/ subfolders).{hint}"
2808        ),
2809        &csp_nonce,
2810    )
2811}
2812
2813/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
2814fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2815    fs::read_dir(dir)
2816        .ok()?
2817        .flatten()
2818        .map(|e| e.path())
2819        .find(|p| {
2820            p.is_file()
2821                && p.file_stem()
2822                    .and_then(|n| n.to_str())
2823                    .is_some_and(|n| n.starts_with("result"))
2824                && p.extension()
2825                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2826        })
2827}
2828
2829#[derive(Deserialize)]
2830struct LocateReportsDirForm {
2831    folder_path: String,
2832}
2833
2834#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
2835async fn locate_reports_dir_handler(
2836    State(state): State<AppState>,
2837    Form(form): Form<LocateReportsDirForm>,
2838) -> impl IntoResponse {
2839    if state.server_mode {
2840        return StatusCode::NOT_FOUND.into_response();
2841    }
2842    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2843        Ok(p) => strip_unc_prefix(p),
2844        Err(_) => {
2845            return axum::response::Redirect::to(
2846                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2847            )
2848            .into_response();
2849        }
2850    };
2851    if !folder.is_dir() {
2852        return axum::response::Redirect::to(
2853            "/view-reports?error=Selected+path+is+not+a+directory.",
2854        )
2855        .into_response();
2856    }
2857
2858    let candidates = collect_result_json_candidates(&folder);
2859
2860    if candidates.is_empty() {
2861        return axum::response::Redirect::to(
2862            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2863        )
2864        .into_response();
2865    }
2866
2867    let mut linked_count: usize = 0;
2868    let mut reg = state.registry.lock().await;
2869    for json_path in candidates {
2870        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2871            continue;
2872        };
2873        if is_dir_already_registered(&reg, &parent) {
2874            continue;
2875        }
2876        let Some(entry) = build_registry_entry_from_json(json_path) else {
2877            continue;
2878        };
2879        reg.add_entry(entry);
2880        linked_count += 1;
2881    }
2882    let _ = reg.save(&state.registry_path);
2883    drop(reg);
2884
2885    if linked_count == 0 {
2886        return axum::response::Redirect::to(
2887            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2888        )
2889        .into_response();
2890    }
2891    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2892}
2893
2894#[derive(Deserialize)]
2895struct RelocateScanForm {
2896    run_id: String,
2897    folder_path: String,
2898    redirect_url: String,
2899}
2900
2901/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
2902/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
2903fn relocate_folder_err(
2904    want_json: bool,
2905    status: StatusCode,
2906    msg: &str,
2907    run_id: &str,
2908    folder_hint: &str,
2909    redirect_url: &str,
2910    csp_nonce: &str,
2911) -> Response {
2912    if want_json {
2913        (
2914            status,
2915            axum::Json(serde_json::json!({"ok": false, "message": msg})),
2916        )
2917            .into_response()
2918    } else {
2919        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
2920    }
2921}
2922
2923#[allow(clippy::too_many_lines)]
2924async fn relocate_scan_handler(
2925    State(state): State<AppState>,
2926    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2927    headers: axum::http::HeaderMap,
2928    Form(form): Form<RelocateScanForm>,
2929) -> impl IntoResponse {
2930    let want_json = headers
2931        .get(axum::http::header::ACCEPT)
2932        .and_then(|v| v.to_str().ok())
2933        .is_some_and(|v| v.contains("application/json"));
2934    if state.server_mode {
2935        return StatusCode::NOT_FOUND.into_response();
2936    }
2937
2938    let run_id = form.run_id.trim().to_string();
2939    let redirect_url = form.redirect_url.trim().to_string();
2940
2941    let run_exists = {
2942        let reg = state.registry.lock().await;
2943        reg.find_by_run_id(&run_id).is_some()
2944    };
2945    if !run_exists {
2946        if want_json {
2947            return (
2948                StatusCode::NOT_FOUND,
2949                axum::Json(serde_json::json!({
2950                    "ok": false,
2951                    "message": format!("Run ID '{run_id}' not found in registry.")
2952                })),
2953            )
2954                .into_response();
2955        }
2956        let html = ErrorTemplate {
2957            message: format!("Run ID '{run_id}' not found in registry."),
2958            last_report_url: Some("/compare-scans".to_string()),
2959            last_report_label: Some("Compare Scans".to_string()),
2960            run_id: Some(run_id.clone()),
2961            error_code: Some(404),
2962            csp_nonce: csp_nonce.clone(),
2963            version: env!("CARGO_PKG_VERSION"),
2964        }
2965        .render()
2966        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2967        return Html(html).into_response();
2968    }
2969
2970    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2971        Ok(p) => strip_unc_prefix(p),
2972        Err(_) => {
2973            return relocate_folder_err(
2974                want_json,
2975                StatusCode::UNPROCESSABLE_ENTITY,
2976                "Folder not found or path is invalid.",
2977                &run_id,
2978                form.folder_path.trim(),
2979                &redirect_url,
2980                &csp_nonce,
2981            );
2982        }
2983    };
2984    if !folder.is_dir() {
2985        return relocate_folder_err(
2986            want_json,
2987            StatusCode::UNPROCESSABLE_ENTITY,
2988            "Selected path is not a directory.",
2989            &run_id,
2990            &folder.display().to_string(),
2991            &redirect_url,
2992            &csp_nonce,
2993        );
2994    }
2995
2996    let json_candidates = find_result_files_by_ext(&folder, "json");
2997    if json_candidates.is_empty() {
2998        let msg = format!(
2999            "No result JSON files found in the selected folder.\nSearched: {}",
3000            folder.display()
3001        );
3002        return relocate_folder_err(
3003            want_json,
3004            StatusCode::UNPROCESSABLE_ENTITY,
3005            &msg,
3006            &run_id,
3007            &folder.display().to_string(),
3008            &redirect_url,
3009            &csp_nonce,
3010        );
3011    }
3012
3013    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3014        let msg = format!(
3015            "No matching scan found in the selected folder.\n\
3016             The JSON files present do not contain run ID: {run_id}\n\
3017             Searched: {}",
3018            folder.display()
3019        );
3020        return relocate_folder_err(
3021            want_json,
3022            StatusCode::UNPROCESSABLE_ENTITY,
3023            &msg,
3024            &run_id,
3025            &folder.display().to_string(),
3026            &redirect_url,
3027            &csp_nonce,
3028        );
3029    };
3030
3031    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3032    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3033    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3034
3035    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3036        redirect_url
3037    } else {
3038        "/compare-scans".to_string()
3039    };
3040    redirect_or_json_ok(want_json, &safe_redirect)
3041}
3042
3043fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3044    let mut out = Vec::new();
3045    collect_scan_files_by_ext(folder, ext, &mut out);
3046    if let Ok(rd) = fs::read_dir(folder) {
3047        for entry in rd.flatten() {
3048            let sub = entry.path();
3049            if sub.is_dir() {
3050                collect_scan_files_by_ext(&sub, ext, &mut out);
3051            }
3052        }
3053    }
3054    out
3055}
3056
3057fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3058    let Ok(rd) = fs::read_dir(dir) else { return };
3059    for entry in rd.flatten() {
3060        let p = entry.path();
3061        if p.is_file()
3062            && p.file_stem()
3063                .and_then(|n| n.to_str())
3064                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3065            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3066        {
3067            out.push(p);
3068        }
3069    }
3070}
3071
3072fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3073    candidates
3074        .iter()
3075        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3076        .cloned()
3077}
3078
3079async fn update_run_file_paths(
3080    state: &AppState,
3081    run_id: &str,
3082    json_path: PathBuf,
3083    html_path: Option<PathBuf>,
3084    pdf_path: Option<PathBuf>,
3085) {
3086    let mut reg = state.registry.lock().await;
3087    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3088        entry.json_path = Some(json_path);
3089        if let Some(hp) = html_path {
3090            entry.html_path = Some(hp);
3091        }
3092        if let Some(pp) = pdf_path {
3093            entry.pdf_path = Some(pp);
3094        }
3095    }
3096    let _ = reg.save(&state.registry_path);
3097}
3098
3099fn missing_scan_relocate_response(
3100    message: &str,
3101    run_id: &str,
3102    folder_hint: &str,
3103    redirect_url: &str,
3104    server_mode: bool,
3105    csp_nonce: &str,
3106) -> axum::response::Response {
3107    let html = RelocateScanTemplate {
3108        message: message.to_string(),
3109        run_id: run_id.to_string(),
3110        folder_hint: folder_hint.to_string(),
3111        redirect_url: redirect_url.to_string(),
3112        server_mode,
3113        csp_nonce: csp_nonce.to_owned(),
3114        version: env!("CARGO_PKG_VERSION"),
3115    }
3116    .render()
3117    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3118    (StatusCode::NOT_FOUND, Html(html)).into_response()
3119}
3120
3121// ── Watched-directory helpers ─────────────────────────────────────────────────
3122
3123/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3124fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3125    let mut candidates = Vec::new();
3126    if let Some(j) = find_result_json_in_dir(folder) {
3127        candidates.push(j);
3128    }
3129    if let Ok(dir_entries) = fs::read_dir(folder) {
3130        for entry in dir_entries.flatten() {
3131            let sub = entry.path();
3132            if sub.is_dir() {
3133                if let Some(j) = find_result_json_in_dir(&sub) {
3134                    candidates.push(j);
3135                }
3136            }
3137        }
3138    }
3139    candidates
3140}
3141
3142fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3143    reg.entries.iter().any(|e| {
3144        let dir_match = e
3145            .json_path
3146            .as_ref()
3147            .and_then(|p| p.parent())
3148            .is_some_and(|p| p == parent)
3149            || e.html_path
3150                .as_ref()
3151                .and_then(|p| p.parent())
3152                .is_some_and(|p| p == parent);
3153        dir_match
3154            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3155                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3156    })
3157}
3158
3159fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3160    let parent = json_path.parent()?.to_path_buf();
3161    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3162        rd.flatten()
3163            .map(|e| e.path())
3164            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3165    });
3166    let run = read_json(&json_path).ok()?;
3167    let project_label = run.input_roots.first().map_or_else(
3168        || "Unknown Project".to_string(),
3169        |r| sanitize_project_label(r),
3170    );
3171    Some(RegistryEntry {
3172        run_id: run.tool.run_id.clone(),
3173        timestamp_utc: run.tool.timestamp_utc,
3174        project_label,
3175        input_roots: run.input_roots.clone(),
3176        json_path: Some(json_path),
3177        html_path,
3178        pdf_path: None,
3179        csv_path: None,
3180        xlsx_path: None,
3181        summary: ScanSummarySnapshot {
3182            files_analyzed: run.summary_totals.files_analyzed,
3183            files_skipped: run.summary_totals.files_skipped,
3184            total_physical_lines: run.summary_totals.total_physical_lines,
3185            code_lines: run.summary_totals.code_lines,
3186            comment_lines: run.summary_totals.comment_lines,
3187            blank_lines: run.summary_totals.blank_lines,
3188            functions: run.summary_totals.functions,
3189            classes: run.summary_totals.classes,
3190            variables: run.summary_totals.variables,
3191            imports: run.summary_totals.imports,
3192            test_count: run.summary_totals.test_count,
3193            coverage_lines_found: run.summary_totals.coverage_lines_found,
3194            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3195            coverage_functions_found: run.summary_totals.coverage_functions_found,
3196            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3197            coverage_branches_found: run.summary_totals.coverage_branches_found,
3198            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3199        },
3200        git_branch: run.git_branch.clone(),
3201        git_commit: run.git_commit_short.clone(),
3202        git_author: run.git_commit_author.clone(),
3203        git_tags: run.git_tags.clone(),
3204        git_nearest_tag: run.git_nearest_tag.clone(),
3205        git_commit_date: run.git_commit_date,
3206    })
3207}
3208
3209/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3210/// Returns the number of newly linked entries.
3211fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3212    let mut linked = 0usize;
3213    for json_path in collect_result_json_candidates(folder) {
3214        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3215            continue;
3216        };
3217        if is_dir_already_registered(reg, &parent) {
3218            continue;
3219        }
3220        let Some(entry) = build_registry_entry_from_json(json_path) else {
3221            continue;
3222        };
3223        reg.add_entry(entry);
3224        linked += 1;
3225    }
3226    linked
3227}
3228
3229/// Scan all watched directories (plus the default output root) into `reg`.
3230async fn auto_scan_watched_dirs(state: &AppState) {
3231    let dirs: Vec<PathBuf> = {
3232        let wd = state.watched_dirs.lock().await;
3233        wd.dirs.clone()
3234    };
3235    if dirs.is_empty() {
3236        return;
3237    }
3238    let mut reg = state.registry.lock().await;
3239    let mut total = 0usize;
3240    for dir in &dirs {
3241        if dir.is_dir() {
3242            total += scan_folder_into_registry(dir, &mut reg);
3243        }
3244    }
3245    if total > 0 {
3246        let _ = reg.save(&state.registry_path);
3247    }
3248}
3249
3250// ── Watched-dir route forms ───────────────────────────────────────────────────
3251
3252#[derive(Deserialize)]
3253struct WatchedDirForm {
3254    folder_path: String,
3255    #[serde(default = "default_redirect")]
3256    redirect_to: String,
3257}
3258
3259fn default_redirect() -> String {
3260    "/view-reports".to_string()
3261}
3262
3263#[derive(Deserialize)]
3264struct WatchedDirRefreshForm {
3265    #[serde(default = "default_redirect")]
3266    redirect_to: String,
3267}
3268
3269// ── Watched-dir helpers ───────────────────────────────────────────────────────
3270
3271/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3272fn safe_redirect(dest: &str) -> &str {
3273    if dest.starts_with('/') {
3274        dest
3275    } else {
3276        "/"
3277    }
3278}
3279
3280// ── Watched-dir handlers ──────────────────────────────────────────────────────
3281
3282async fn add_watched_dir_handler(
3283    State(state): State<AppState>,
3284    Form(form): Form<WatchedDirForm>,
3285) -> impl IntoResponse {
3286    if state.server_mode {
3287        return StatusCode::NOT_FOUND.into_response();
3288    }
3289    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3290        strip_unc_prefix(p)
3291    } else {
3292        let dest = format!(
3293            "{}?error=Folder+not+found+or+path+is+invalid.",
3294            safe_redirect(&form.redirect_to)
3295        );
3296        return axum::response::Redirect::to(&dest).into_response();
3297    };
3298    if !folder.is_dir() {
3299        let dest = format!(
3300            "{}?error=Selected+path+is+not+a+directory.",
3301            safe_redirect(&form.redirect_to)
3302        );
3303        return axum::response::Redirect::to(&dest).into_response();
3304    }
3305
3306    // Persist the watched directory.
3307    {
3308        let mut wd = state.watched_dirs.lock().await;
3309        wd.add(folder.clone());
3310        let _ = wd.save(&state.watched_dirs_path);
3311    }
3312
3313    // Immediately scan the folder and add any new reports.
3314    let linked = {
3315        let mut reg = state.registry.lock().await;
3316        let n = scan_folder_into_registry(&folder, &mut reg);
3317        if n > 0 {
3318            let _ = reg.save(&state.registry_path);
3319        }
3320        n
3321    };
3322
3323    let dest = if linked > 0 {
3324        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3325    } else {
3326        format!(
3327            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3328            safe_redirect(&form.redirect_to)
3329        )
3330    };
3331    axum::response::Redirect::to(&dest).into_response()
3332}
3333
3334async fn remove_watched_dir_handler(
3335    State(state): State<AppState>,
3336    Form(form): Form<WatchedDirForm>,
3337) -> impl IntoResponse {
3338    if state.server_mode {
3339        return StatusCode::NOT_FOUND.into_response();
3340    }
3341    let folder = PathBuf::from(&form.folder_path);
3342    {
3343        let mut wd = state.watched_dirs.lock().await;
3344        wd.remove(&folder);
3345        let _ = wd.save(&state.watched_dirs_path);
3346    }
3347    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3348}
3349
3350async fn refresh_watched_dirs_handler(
3351    State(state): State<AppState>,
3352    Form(form): Form<WatchedDirRefreshForm>,
3353) -> impl IntoResponse {
3354    if state.server_mode {
3355        return StatusCode::NOT_FOUND.into_response();
3356    }
3357    let dirs: Vec<PathBuf> = {
3358        let wd = state.watched_dirs.lock().await;
3359        wd.dirs.clone()
3360    };
3361    let mut total = 0usize;
3362    {
3363        let mut reg = state.registry.lock().await;
3364        for dir in &dirs {
3365            if dir.is_dir() {
3366                total += scan_folder_into_registry(dir, &mut reg);
3367            }
3368        }
3369        if total > 0 {
3370            let _ = reg.save(&state.registry_path);
3371        }
3372    }
3373    let dest = if total > 0 {
3374        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3375    } else {
3376        safe_redirect(&form.redirect_to).to_owned()
3377    };
3378    axum::response::Redirect::to(&dest).into_response()
3379}
3380
3381#[derive(Debug, Deserialize)]
3382struct OpenPathQuery {
3383    path: Option<String>,
3384}
3385
3386fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3387    let mut ancestor = std::path::Path::new(raw);
3388    loop {
3389        match ancestor.parent() {
3390            Some(p) => {
3391                ancestor = p;
3392                if ancestor.is_dir() {
3393                    break;
3394                }
3395            }
3396            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3397        }
3398    }
3399    Ok(ancestor.to_path_buf())
3400}
3401
3402async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3403    match tokio::fs::canonicalize(raw).await {
3404        Ok(canonical) if canonical.is_file() => canonical
3405            .parent()
3406            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3407                Ok(p.to_path_buf())
3408            }),
3409        Ok(canonical) if canonical.is_dir() => Ok(canonical),
3410        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3411        Err(_) => find_existing_ancestor(raw),
3412    }
3413}
3414
3415async fn open_path_handler(
3416    State(state): State<AppState>,
3417    Query(query): Query<OpenPathQuery>,
3418) -> impl IntoResponse {
3419    if state.server_mode {
3420        return Json(serde_json::json!({
3421            "server_mode_disabled": true,
3422            "message": "Opening a path in the file manager is only available in local desktop mode."
3423        }))
3424        .into_response();
3425    }
3426    // Skip the OS file-manager call in headless / CI environments.
3427    if std::env::var("SLOC_HEADLESS").is_ok() {
3428        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3429    }
3430    let raw = match query.path.as_deref() {
3431        Some(p) if !p.is_empty() => p,
3432        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3433    };
3434
3435    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
3436    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
3437    // so the file explorer still opens somewhere useful.
3438    let target = match resolve_open_target(raw).await {
3439        Ok(p) => p,
3440        Err((code, msg)) => return (code, msg).into_response(),
3441    };
3442
3443    #[cfg(target_os = "windows")]
3444    win_dialog_focus::open_folder_foreground(target);
3445    #[cfg(target_os = "macos")]
3446    let _ = std::process::Command::new("open")
3447        .arg(&target)
3448        .stdout(Stdio::null())
3449        .stderr(Stdio::null())
3450        .spawn();
3451    #[cfg(target_os = "linux")]
3452    {
3453        let folder_name = target
3454            .file_name()
3455            .and_then(|n| n.to_str())
3456            .map(str::to_owned);
3457        let _ = std::process::Command::new("xdg-open")
3458            .arg(&target)
3459            .stdout(Stdio::null())
3460            .stderr(Stdio::null())
3461            .spawn();
3462        // Best-effort: raise the file manager window once it appears.
3463        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
3464        // installed; failures are silently discarded.
3465        if let Some(name) = folder_name {
3466            std::thread::spawn(move || {
3467                std::thread::sleep(std::time::Duration::from_millis(800));
3468                let _ = std::process::Command::new("wmctrl")
3469                    .args(["-a", &name])
3470                    .stdout(Stdio::null())
3471                    .stderr(Stdio::null())
3472                    .spawn();
3473            });
3474        }
3475    }
3476
3477    Json(serde_json::json!({"ok": true})).into_response()
3478}
3479
3480async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3481    let (content_type, bytes): (&'static str, &'static [u8]) =
3482        match (folder.as_str(), file.as_str()) {
3483            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3484            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3485            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3486            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3487            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3488            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3489            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3490            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3491            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3492            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3493            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3494            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3495            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3496            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3497            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3498            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3499            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3500            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3501            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3502            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3503            _ => return StatusCode::NOT_FOUND.into_response(),
3504        };
3505    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3506}
3507
3508async fn preview_handler(
3509    State(state): State<AppState>,
3510    Query(query): Query<PreviewQuery>,
3511) -> impl IntoResponse {
3512    let raw_path = query
3513        .path
3514        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3515    let resolved = resolve_input_path(&raw_path);
3516
3517    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3518    // binary whose working directory is not the project root), return a clear message
3519    // instead of an opaque OS error from build_preview_html.
3520    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3521        return Html(
3522            r#"<div class="preview-error">Sample directory not available on this server.
3523            Enter a path to a project directory or upload files using Browse.</div>"#
3524                .to_string(),
3525        );
3526    }
3527
3528    if state.server_mode {
3529        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3530        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3531        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3532            let config = &state.base_config;
3533            if config.discovery.allowed_scan_roots.is_empty() {
3534                return Html(
3535                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3536                );
3537            }
3538            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3539                fs::canonicalize(root)
3540                    .ok()
3541                    .is_some_and(|r| canonical.starts_with(&r))
3542            });
3543            if !allowed {
3544                return Html(
3545                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3546                );
3547            }
3548        }
3549    }
3550
3551    let include_patterns = split_patterns(query.include_globs.as_deref());
3552    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3553
3554    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3555        Ok(html) => Html(html),
3556        Err(err) => Html(format!(
3557            r#"<div class="preview-error">Preview failed: {}</div>"#,
3558            escape_html(&err.to_string())
3559        )),
3560    }
3561}
3562
3563#[derive(Debug, Deserialize, Default)]
3564struct SuggestCoverageQuery {
3565    path: Option<String>,
3566}
3567
3568#[derive(Serialize)]
3569struct SuggestCoverageResponse {
3570    found: Option<String>,
3571    tool: Option<&'static str>,
3572    hint: Option<&'static str>,
3573}
3574
3575async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3576    const CANDIDATES: &[&str] = &[
3577        // LCOV — cargo-llvm-cov, gcov, lcov
3578        "coverage/lcov.info",
3579        "lcov.info",
3580        "target/llvm-cov/lcov.info",
3581        "target/coverage/lcov.info",
3582        "target/debug/coverage/lcov.info",
3583        "coverage/coverage.lcov",
3584        "build/coverage/lcov.info",
3585        "reports/lcov.info",
3586        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3587        "coverage.xml",
3588        "coverage/coverage.xml",
3589        "target/site/cobertura/coverage.xml",
3590        "build/reports/coverage/coverage.xml",
3591        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3592        "target/site/jacoco/jacoco.xml",
3593        "build/reports/jacoco/test/jacocoTestReport.xml",
3594        "build/reports/jacoco/jacocoTestReport.xml",
3595        "build/jacoco/jacoco.xml",
3596    ];
3597    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3598    let found = CANDIDATES
3599        .iter()
3600        .map(|rel| root.join(rel))
3601        .find(|p| p.is_file())
3602        .map(|p| display_path(&p));
3603
3604    let (tool, hint) = detect_coverage_tool(&root);
3605    Json(SuggestCoverageResponse { found, tool, hint })
3606}
3607
3608/// Inspect the project root for known build/package files and return the most likely coverage
3609/// tool name and the shell command needed to generate a coverage file.
3610fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3611    if root.join("Cargo.toml").is_file() {
3612        return (
3613            Some("cargo-llvm-cov"),
3614            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3615        );
3616    }
3617    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3618        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3619    }
3620    if root.join("pom.xml").is_file() {
3621        return (Some("jacoco"), Some("mvn test jacoco:report"));
3622    }
3623    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3624        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3625    }
3626    (None, None)
3627}
3628
3629/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3630#[allow(clippy::result_large_err)]
3631fn validate_server_scan_path(
3632    config: &sloc_config::AppConfig,
3633    resolved_path: &Path,
3634    csp_nonce: &str,
3635) -> Result<(), Response> {
3636    if config.discovery.allowed_scan_roots.is_empty() {
3637        let template = ErrorTemplate {
3638            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3639                      Set allowed_scan_roots in the server config to permit scanning."
3640                .to_string(),
3641            last_report_url: None,
3642            last_report_label: None,
3643            run_id: None,
3644            error_code: Some(403),
3645            csp_nonce: csp_nonce.to_owned(),
3646            version: env!("CARGO_PKG_VERSION"),
3647        };
3648        return Err((
3649            StatusCode::FORBIDDEN,
3650            Html(
3651                template
3652                    .render()
3653                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3654            ),
3655        )
3656            .into_response());
3657    }
3658    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3659    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3660        fs::canonicalize(root)
3661            .ok()
3662            .is_some_and(|r| canonical.starts_with(&r))
3663    });
3664    if !allowed {
3665        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3666            "Scan path not in allowed_scan_roots");
3667        let template = ErrorTemplate {
3668            message: "The requested path is not within an allowed scan directory.".to_string(),
3669            last_report_url: None,
3670            last_report_label: None,
3671            run_id: None,
3672            error_code: Some(403),
3673            csp_nonce: csp_nonce.to_owned(),
3674            version: env!("CARGO_PKG_VERSION"),
3675        };
3676        return Err((
3677            StatusCode::FORBIDDEN,
3678            Html(
3679                template
3680                    .render()
3681                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3682            ),
3683        )
3684            .into_response());
3685    }
3686    Ok(())
3687}
3688
3689/// Exclude the output directory from scanning so artifacts don't pollute counts.
3690fn apply_output_dir_exclusions(
3691    config: &mut sloc_config::AppConfig,
3692    project_path: &str,
3693    raw_output_dir: &str,
3694) {
3695    let project_root = resolve_input_path(project_path);
3696    let raw_out = raw_output_dir.trim();
3697    let resolved_out = if raw_out.is_empty() {
3698        project_root.join("sloc")
3699    } else if Path::new(raw_out).is_absolute() {
3700        PathBuf::from(raw_out)
3701    } else {
3702        workspace_root().join(raw_out)
3703    };
3704    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3705        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3706            let dir = first.to_string();
3707            if !config.discovery.excluded_directories.contains(&dir) {
3708                config.discovery.excluded_directories.push(dir);
3709            }
3710        }
3711    }
3712    if !config
3713        .discovery
3714        .excluded_directories
3715        .iter()
3716        .any(|d| d == "sloc")
3717    {
3718        config
3719            .discovery
3720            .excluded_directories
3721            .push("sloc".to_string());
3722    }
3723}
3724
3725/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3726const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3727    ScanSummarySnapshot {
3728        files_analyzed: run.summary_totals.files_analyzed,
3729        files_skipped: run.summary_totals.files_skipped,
3730        total_physical_lines: run.summary_totals.total_physical_lines,
3731        code_lines: run.summary_totals.code_lines,
3732        comment_lines: run.summary_totals.comment_lines,
3733        blank_lines: run.summary_totals.blank_lines,
3734        functions: run.summary_totals.functions,
3735        classes: run.summary_totals.classes,
3736        variables: run.summary_totals.variables,
3737        imports: run.summary_totals.imports,
3738        test_count: run.summary_totals.test_count,
3739        coverage_lines_found: run.summary_totals.coverage_lines_found,
3740        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3741        coverage_functions_found: run.summary_totals.coverage_functions_found,
3742        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3743        coverage_branches_found: run.summary_totals.coverage_branches_found,
3744        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3745    }
3746}
3747
3748/// Build the `RegistryEntry` for the just-completed scan run.
3749pub(crate) fn build_run_registry_entry(
3750    run: &AnalysisRun,
3751    run_id: &str,
3752    project_label: &str,
3753    artifacts: &RunArtifacts,
3754) -> RegistryEntry {
3755    RegistryEntry {
3756        run_id: run_id.to_owned(),
3757        timestamp_utc: run.tool.timestamp_utc,
3758        project_label: project_label.to_owned(),
3759        input_roots: run.input_roots.clone(),
3760        json_path: artifacts.json_path.clone(),
3761        html_path: artifacts.html_path.clone(),
3762        pdf_path: artifacts.pdf_path.clone(),
3763        csv_path: artifacts.csv_path.clone(),
3764        xlsx_path: artifacts.xlsx_path.clone(),
3765        summary: summary_snapshot_from_run(run),
3766        git_branch: run.git_branch.clone(),
3767        git_commit: run.git_commit_short.clone(),
3768        git_author: run.git_commit_author.clone(),
3769        git_tags: run.git_tags.clone(),
3770        git_nearest_tag: run.git_nearest_tag.clone(),
3771        git_commit_date: run.git_commit_date.clone(),
3772    }
3773}
3774
3775/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3776fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3777    if let Some(policy) = form.mixed_line_policy {
3778        config.analysis.mixed_line_policy = policy;
3779    }
3780    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3781    config.analysis.generated_file_detection =
3782        form.generated_file_detection.as_deref() != Some("disabled");
3783    config.analysis.minified_file_detection =
3784        form.minified_file_detection.as_deref() != Some("disabled");
3785    config.analysis.vendor_directory_detection =
3786        form.vendor_directory_detection.as_deref() != Some("disabled");
3787    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3788    if let Some(binary_behavior) = form.binary_file_behavior {
3789        config.analysis.binary_file_behavior = binary_behavior;
3790    }
3791    apply_report_opts(config, form);
3792    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3793    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3794    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3795    if let Some(policy) = form.continuation_line_policy {
3796        config.analysis.continuation_line_policy = policy;
3797    }
3798    if let Some(policy) = form.blank_in_block_comment_policy {
3799        config.analysis.blank_in_block_comment_policy = policy;
3800    }
3801    config.analysis.count_compiler_directives =
3802        form.count_compiler_directives.as_deref() != Some("disabled");
3803    apply_style_threshold(config, form);
3804    apply_coverage_path(config, form);
3805}
3806
3807fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3808    if let Some(report_title) = form.report_title.as_deref() {
3809        let trimmed = report_title.trim();
3810        if !trimmed.is_empty() {
3811            config.reporting.report_title = trimmed.to_string();
3812        }
3813    }
3814    if let Some(hf) = form.report_header_footer.as_deref() {
3815        let trimmed = hf.trim();
3816        config.reporting.report_header_footer = if trimmed.is_empty() {
3817            None
3818        } else {
3819            Some(trimmed.to_string())
3820        };
3821    }
3822}
3823
3824fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3825    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3826        if let Ok(t) = threshold_str.parse::<u16>() {
3827            if t == 80 || t == 100 || t == 120 {
3828                config.analysis.style_col_threshold = t;
3829            }
3830        }
3831    }
3832    if let Some(v) = form.style_analysis_enabled.as_deref() {
3833        config.analysis.style_analysis_enabled = v != "disabled";
3834    }
3835    if let Some(v) = form.style_score_threshold.as_deref() {
3836        if let Ok(t) = v.parse::<u8>() {
3837            config.analysis.style_score_threshold = t.min(100);
3838        }
3839    }
3840    if let Some(v) = form.style_lang_scope.as_deref() {
3841        let scope = v.trim();
3842        if scope == "c_family" || scope == "all" {
3843            config.analysis.style_lang_scope = scope.to_string();
3844        }
3845    }
3846}
3847
3848fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3849    if let Some(cov) = &form.coverage_file {
3850        let trimmed = cov.trim();
3851        if !trimmed.is_empty() {
3852            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3853        }
3854    }
3855}
3856
3857/// Fire-and-forget: generate the PDF in a background task if one is pending.
3858/// On failure, clears `pdf_path` in the artifacts map so the results page shows
3859/// an error instead of spinning indefinitely.
3860fn spawn_pdf_background(
3861    pending_pdf: PendingPdf,
3862    run_id: String,
3863    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3864) {
3865    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3866        tokio::spawn(async move {
3867            let result = tokio::task::spawn_blocking(move || {
3868                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3869                if cleanup_src {
3870                    let _ = fs::remove_file(&pdf_src);
3871                }
3872                r
3873            })
3874            .await;
3875            let failed = match result {
3876                Ok(Ok(())) => false,
3877                Ok(Err(err)) => {
3878                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3879                    true
3880                }
3881                Err(err) => {
3882                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3883                    true
3884                }
3885            };
3886            if failed {
3887                let mut map = artifacts.lock().await;
3888                if let Some(entry) = map.get_mut(&run_id) {
3889                    entry.pdf_path = None;
3890                }
3891            }
3892        });
3893    }
3894}
3895
3896/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
3897/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
3898/// result page can show an error on the next visit instead of spinning indefinitely.
3899fn spawn_native_pdf_background(
3900    json_path: PathBuf,
3901    pdf_dest: PathBuf,
3902    run_id: String,
3903    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3904) {
3905    tokio::spawn(async move {
3906        let result = tokio::task::spawn_blocking(move || {
3907            let run = sloc_core::read_json(&json_path)?;
3908            write_pdf_from_run(&run, &pdf_dest)
3909        })
3910        .await;
3911        let failed = match result {
3912            Ok(Ok(())) => false,
3913            Ok(Err(err)) => {
3914                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3915                true
3916            }
3917            Err(err) => {
3918                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3919                true
3920            }
3921        };
3922        if failed {
3923            let mut map = artifacts.lock().await;
3924            if let Some(entry) = map.get_mut(&run_id) {
3925                entry.pdf_path = None;
3926            }
3927        }
3928    });
3929}
3930
3931/// Sum the code lines added in this comparison (new + grown files).
3932fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3933    cmp.file_deltas
3934        .iter()
3935        .map(|f| match f.status {
3936            FileChangeStatus::Added => f.current_code,
3937            FileChangeStatus::Modified => f.code_delta.max(0),
3938            _ => 0,
3939        })
3940        .sum()
3941}
3942
3943/// Sum the code lines removed in this comparison (deleted + shrunk files).
3944fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3945    cmp.file_deltas
3946        .iter()
3947        .map(|f| match f.status {
3948            FileChangeStatus::Removed => f.baseline_code,
3949            FileChangeStatus::Modified => (-f.code_delta).max(0),
3950            _ => 0,
3951        })
3952        .sum()
3953}
3954
3955/// Sum the code lines present in both scans without any change (Unchanged files).
3956fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3957    cmp.file_deltas
3958        .iter()
3959        .filter(|f| f.status == FileChangeStatus::Unchanged)
3960        .map(|f| f.current_code)
3961        .sum()
3962}
3963
3964/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
3965fn build_submodule_row(
3966    s: &sloc_core::SubmoduleSummary,
3967    run: &AnalysisRun,
3968    run_id: &str,
3969    run_dir: &Path,
3970) -> SubmoduleRow {
3971    let safe = sanitize_project_label(&s.name);
3972    let artifact_key = format!("sub_{safe}");
3973    let pdf_artifact_key = format!("sub_{safe}_pdf");
3974    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
3975        let parent_path = run
3976            .input_roots
3977            .first()
3978            .map_or("", std::string::String::as_str);
3979        let sub_run = build_sub_run(run, s, parent_path);
3980        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
3981        render_sub_report_html(&sub_run, Some(&pdf_server_url))
3982            .ok()
3983            .and_then(|sub_html| {
3984                let sub_dir = run_dir.join("submodules");
3985                let _ = fs::create_dir_all(&sub_dir);
3986                let html_path = sub_dir.join(format!("{artifact_key}.html"));
3987                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
3988                    // Pre-generate the sub-report PDF using the programmatic renderer
3989                    // so "View PDF" never needs to spawn Chrome for submodules.
3990                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
3991                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
3992                    Some(format!("/runs/{artifact_key}/{run_id}"))
3993                } else {
3994                    None
3995                }
3996            })
3997    } else {
3998        None
3999    };
4000    SubmoduleRow {
4001        name: s.name.clone(),
4002        relative_path: s.relative_path.clone(),
4003        files_analyzed: s.files_analyzed,
4004        code_lines: s.code_lines,
4005        comment_lines: s.comment_lines,
4006        blank_lines: s.blank_lines,
4007        total_physical_lines: s.total_physical_lines,
4008        html_url,
4009    }
4010}
4011
4012// Immediately returns a wait page and runs the analysis in a background tokio task.
4013// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4014#[allow(clippy::similar_names)]
4015#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4016#[allow(clippy::too_many_lines)]
4017async fn analyze_handler(
4018    State(state): State<AppState>,
4019    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4020    Form(form): Form<AnalyzeForm>,
4021) -> impl IntoResponse {
4022    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4023        let template = ErrorTemplate {
4024            message: format!(
4025                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4026             Please wait a moment and try again."
4027            ),
4028            last_report_url: None,
4029            last_report_label: None,
4030            run_id: None,
4031            error_code: Some(503),
4032            csp_nonce: csp_nonce.clone(),
4033            version: env!("CARGO_PKG_VERSION"),
4034        };
4035        return (
4036            StatusCode::SERVICE_UNAVAILABLE,
4037            Html(
4038                template
4039                    .render()
4040                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4041            ),
4042        )
4043            .into_response();
4044    };
4045
4046    let mut config = state.base_config.clone();
4047
4048    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4049    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4050    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4051
4052    if !is_git_mode {
4053        let resolved_path = resolve_input_path(&form.path);
4054        if state.server_mode
4055            && !is_upload_tmp_path(&resolved_path)
4056            && !is_sample_path(&resolved_path)
4057        {
4058            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4059                return resp;
4060            }
4061        }
4062        config.discovery.root_paths = vec![resolved_path];
4063    }
4064
4065    apply_form_to_config(&mut config, &form);
4066    apply_output_dir_exclusions(
4067        &mut config,
4068        &form.path,
4069        form.output_dir.as_deref().unwrap_or(""),
4070    );
4071
4072    // Generate a wait_id now (before spawning) so the client can poll for status.
4073    let wait_id = uuid::Uuid::new_v4().to_string();
4074    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4075
4076    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4077    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4078    let task_cancel = Arc::clone(&cancel_token);
4079
4080    // Phase tracker: updated by run_analysis_task at key checkpoints.
4081    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4082    let task_phase = Arc::clone(&phase);
4083
4084    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4085    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4086    let task_files_done = Arc::clone(&files_done);
4087    let task_files_total = Arc::clone(&files_total);
4088
4089    // Register Running state before building the task struct so the semaphore permit
4090    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4091    {
4092        let mut runs = state.async_runs.lock().await;
4093        runs.insert(
4094            wait_id.clone(),
4095            AsyncRunState::Running {
4096                started_at: std::time::Instant::now(),
4097                cancel_token,
4098                phase,
4099                files_done,
4100                files_total,
4101            },
4102        );
4103    }
4104
4105    let task = AnalysisTask {
4106        sem_permit,
4107        state: state.clone(),
4108        wait_id: wait_id.clone(),
4109        config,
4110        cancel: task_cancel,
4111        phase: task_phase,
4112        files_done: task_files_done,
4113        files_total: task_files_total,
4114        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4115        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4116        project_path: form.path.clone(),
4117        // In server mode the client-supplied output_dir is ignored — artifacts are
4118        // always written under the server's configured output root so remote users
4119        // cannot direct writes to arbitrary filesystem paths.
4120        output_dir: if state.server_mode {
4121            None
4122        } else {
4123            form.output_dir.clone()
4124        },
4125        clones_dir: state.git_clones_dir.clone(),
4126    };
4127
4128    tokio::spawn(run_analysis_task(task));
4129
4130    let template = ScanWaitTemplate {
4131        version: env!("CARGO_PKG_VERSION"),
4132        wait_id_json,
4133        project_path: form.path.clone(),
4134        csp_nonce,
4135    };
4136    let html = template
4137        .render()
4138        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4139    let mut response = Html(html).into_response();
4140    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4141        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4142            response.headers_mut().insert(name, val);
4143        }
4144    }
4145    response
4146}
4147
4148struct AnalysisTask {
4149    sem_permit: tokio::sync::OwnedSemaphorePermit,
4150    state: AppState,
4151    wait_id: String,
4152    config: AppConfig,
4153    cancel: Arc<std::sync::atomic::AtomicBool>,
4154    phase: Arc<std::sync::Mutex<String>>,
4155    files_done: Arc<std::sync::atomic::AtomicUsize>,
4156    files_total: Arc<std::sync::atomic::AtomicUsize>,
4157    git_repo: Option<String>,
4158    git_ref: Option<String>,
4159    project_path: String,
4160    output_dir: Option<String>,
4161    clones_dir: PathBuf,
4162}
4163
4164#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4165async fn run_analysis_task(task: AnalysisTask) {
4166    let _permit = task.sem_permit;
4167
4168    let cancel_sb = Arc::clone(&task.cancel);
4169    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4170    let clones_dir_sb = task.clones_dir;
4171    // Save the upload staging path before config is moved into spawn_blocking.
4172    let upload_staging_root = task
4173        .config
4174        .discovery
4175        .root_paths
4176        .first()
4177        .filter(|p| is_upload_tmp_path(p))
4178        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4179        .map(PathBuf::from);
4180    let config_sb = task.config;
4181    let progress_sb = sloc_core::ProgressCounters {
4182        files_done: Arc::clone(&task.files_done),
4183        files_total: Arc::clone(&task.files_total),
4184    };
4185    if let Ok(mut p) = task.phase.lock() {
4186        *p = "Scanning files".to_string();
4187    }
4188    let analysis_result = tokio::task::spawn_blocking(move || {
4189        run_analysis_blocking(
4190            config_sb,
4191            git_repo_sb,
4192            git_ref_sb,
4193            clones_dir_sb,
4194            cancel_sb,
4195            Some(progress_sb),
4196        )
4197    })
4198    .await
4199    .map_err(|err| anyhow::anyhow!(err.to_string()))
4200    .and_then(|result| result);
4201
4202    if let Ok(mut p) = task.phase.lock() {
4203        *p = "Writing reports".to_string();
4204    }
4205
4206    // If cancelled while running, discard results and mark as cancelled.
4207    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4208        let mut runs = task.state.async_runs.lock().await;
4209        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4210        if matches!(
4211            runs.get(&task.wait_id),
4212            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4213        ) {
4214            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4215        }
4216        drop(runs);
4217        return;
4218    }
4219
4220    let run = match analysis_result {
4221        Ok(v) => v,
4222        Err(err) => {
4223            // Distinguish user-cancelled from real failure.
4224            if err.to_string().contains("analysis cancelled") {
4225                let mut runs = task.state.async_runs.lock().await;
4226                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4227                drop(runs);
4228                return;
4229            }
4230            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4231            let mut runs = task.state.async_runs.lock().await;
4232            runs.insert(
4233                task.wait_id.clone(),
4234                AsyncRunState::Failed {
4235                    message: "Analysis failed. Check that the path exists and is readable."
4236                        .to_string(),
4237                },
4238            );
4239            drop(runs);
4240            return;
4241        }
4242    };
4243
4244    let run_id = run.tool.run_id.clone();
4245    tracing::info!(event = "scan_complete", run_id = %run_id,
4246        path = %task.project_path, files = run.summary_totals.files_analyzed,
4247        "Analysis finished");
4248
4249    let prev_entry: Option<RegistryEntry> = {
4250        let reg = task.state.registry.lock().await;
4251        reg.entries_for_roots(&run.input_roots)
4252            .into_iter()
4253            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4254            .cloned()
4255    };
4256
4257    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4258        prev.json_path
4259            .as_ref()
4260            .and_then(|p| read_json(p).ok())
4261            .map(|prev_run| compute_delta(&prev_run, &run))
4262    });
4263    let prev_scan_count: usize = {
4264        let reg = task.state.registry.lock().await;
4265        reg.entries_for_roots(&run.input_roots)
4266            .iter()
4267            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4268            .count()
4269    };
4270
4271    // Build the HTML report now that delta is available, so the artifact
4272    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
4273    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4274        .as_ref()
4275        .zip(prev_entry.as_ref())
4276        .map(|(cmp, prev)| ReportDeltaContext {
4277            delta_code_added: sum_added_code_lines(cmp),
4278            delta_code_removed: sum_removed_code_lines(cmp),
4279            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4280            delta_files_added: cmp.files_added,
4281            delta_files_removed: cmp.files_removed,
4282            delta_files_modified: cmp.files_modified,
4283            delta_files_unchanged: cmp.files_unchanged,
4284            prev_code_lines: prev.summary.code_lines,
4285            prev_scan_count: prev_scan_count + 1,
4286            prev_scan_label: fmt_la_time(prev.timestamp_utc),
4287            prev_run_id: Some(prev.run_id.clone()),
4288            current_run_id: Some(run_id.clone()),
4289        });
4290    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4291        Ok(h) => h,
4292        Err(err) => {
4293            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4294            let mut runs = task.state.async_runs.lock().await;
4295            runs.insert(
4296                task.wait_id.clone(),
4297                AsyncRunState::Failed {
4298                    message: "Failed to render HTML report.".to_string(),
4299                },
4300            );
4301            drop(runs);
4302            return;
4303        }
4304    };
4305
4306    let output_root = resolve_output_root(task.output_dir.as_deref());
4307    let project_label = derive_project_label(
4308        task.git_repo.as_deref(),
4309        task.git_ref.as_deref(),
4310        &task.project_path,
4311    );
4312    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4313    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4314
4315    let result_context = RunResultContext {
4316        prev_entry: prev_entry.clone(),
4317        prev_scan_count,
4318        project_path: task.project_path.clone(),
4319    };
4320
4321    let artifact_result = persist_run_artifacts(
4322        &run,
4323        &report_html,
4324        &run_dir,
4325        &run.effective_configuration.reporting.report_title,
4326        &file_stem,
4327        result_context,
4328    );
4329
4330    let (artifacts, pending_pdf) = match artifact_result {
4331        Ok(v) => v,
4332        Err(err) => {
4333            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4334            let mut runs = task.state.async_runs.lock().await;
4335            runs.insert(
4336                task.wait_id.clone(),
4337                AsyncRunState::Failed {
4338                    message: "Failed to save report artifacts. Check available disk space."
4339                        .to_string(),
4340                },
4341            );
4342            drop(runs);
4343            return;
4344        }
4345    };
4346
4347    {
4348        let mut map = task.state.artifacts.lock().await;
4349        map.insert(run_id.clone(), artifacts.clone());
4350    }
4351
4352    {
4353        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4354        let mut reg = task.state.registry.lock().await;
4355        reg.add_entry(entry);
4356        let _ = reg.save(&task.state.registry_path);
4357    }
4358
4359    if let Some(ref cfg_path) = artifacts.scan_config_path {
4360        save_scan_config_json(
4361            cfg_path,
4362            &run,
4363            &task.project_path,
4364            task.output_dir.as_deref(),
4365        );
4366    }
4367
4368    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4369
4370    prom_runs_total().inc();
4371
4372    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
4373    let mut runs = task.state.async_runs.lock().await;
4374    runs.insert(
4375        task.wait_id.clone(),
4376        AsyncRunState::Complete {
4377            run_id: run_id.clone(),
4378        },
4379    );
4380    drop(runs);
4381
4382    // Remove the client-upload staging directory after a successful scan so
4383    // that uploaded project files don't accumulate in the OS temp directory.
4384    if let Some(staging) = upload_staging_root {
4385        let _ = tokio::fs::remove_dir_all(staging).await;
4386    }
4387
4388    let _ = scan_delta;
4389}
4390
4391fn save_scan_config_json(
4392    cfg_path: &std::path::Path,
4393    run: &sloc_core::AnalysisRun,
4394    project_path: &str,
4395    output_dir: Option<&str>,
4396) {
4397    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4398        .ok()
4399        .and_then(|v| v.as_str().map(String::from))
4400        .unwrap_or_else(|| "code_only".to_string());
4401    let behavior_str =
4402        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4403            .ok()
4404            .and_then(|v| v.as_str().map(String::from))
4405            .unwrap_or_else(|| "skip".to_string());
4406    let scan_cfg = ScanConfig {
4407        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4408        path: project_path.to_string(),
4409        include_globs: run
4410            .effective_configuration
4411            .discovery
4412            .include_globs
4413            .join("\n"),
4414        exclude_globs: run
4415            .effective_configuration
4416            .discovery
4417            .exclude_globs
4418            .join("\n"),
4419        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4420        mixed_line_policy: policy_str,
4421        python_docstrings_as_comments: run
4422            .effective_configuration
4423            .analysis
4424            .python_docstrings_as_comments,
4425        generated_file_detection: run
4426            .effective_configuration
4427            .analysis
4428            .generated_file_detection,
4429        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4430        vendor_directory_detection: run
4431            .effective_configuration
4432            .analysis
4433            .vendor_directory_detection,
4434        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4435        binary_file_behavior: behavior_str,
4436        output_dir: output_dir.unwrap_or("").to_string(),
4437        report_title: run.effective_configuration.reporting.report_title.clone(),
4438    };
4439    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4440        let _ = std::fs::write(cfg_path, json);
4441    }
4442}
4443
4444#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
4445fn run_analysis_blocking(
4446    mut config: AppConfig,
4447    git_repo: Option<String>,
4448    git_ref: Option<String>,
4449    clones_dir: PathBuf,
4450    cancel: Arc<std::sync::atomic::AtomicBool>,
4451    progress: Option<sloc_core::ProgressCounters>,
4452) -> Result<sloc_core::AnalysisRun> {
4453    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4454        let dest = git_clone_dest(&repo, &clones_dir);
4455        sloc_git::clone_or_fetch(&repo, &dest)?;
4456        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4457        sloc_git::create_worktree(&dest, &refname, &wt)?;
4458        config.discovery.root_paths = vec![wt.clone()];
4459        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4460        let _ = sloc_git::destroy_worktree(&dest, &wt);
4461        let mut run = run?;
4462        if run.git_branch.is_none() {
4463            run.git_branch = Some(refname);
4464        }
4465        return Ok(run);
4466    }
4467    analyze(&config, "serve", Some(&cancel), progress.as_ref())
4468}
4469
4470fn derive_project_label(
4471    git_repo: Option<&str>,
4472    git_ref: Option<&str>,
4473    fallback_path: &str,
4474) -> String {
4475    match (
4476        git_repo.filter(|s| !s.is_empty()),
4477        git_ref.filter(|s| !s.is_empty()),
4478    ) {
4479        (Some(repo), Some(refname)) => {
4480            let repo_name = repo
4481                .trim_end_matches('/')
4482                .trim_end_matches(".git")
4483                .rsplit('/')
4484                .next()
4485                .unwrap_or("repo");
4486            sanitize_project_label(&format!("{repo_name}_{refname}"))
4487        }
4488        _ => sanitize_project_label(fallback_path),
4489    }
4490}
4491
4492fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4493    let commit = commit_short.unwrap_or("").trim();
4494    if commit.is_empty() {
4495        project_label.to_string()
4496    } else {
4497        format!("{project_label}_{commit}")
4498    }
4499}
4500
4501// ── Async scan status + result handlers ──────────────────────────────────────
4502
4503#[derive(Serialize)]
4504#[serde(tag = "state", rename_all = "snake_case")]
4505enum AsyncRunStatusResponse {
4506    Running {
4507        elapsed_secs: u64,
4508        phase: String,
4509        files_done: u64,
4510        files_total: u64,
4511    },
4512    Complete {
4513        run_id: String,
4514    },
4515    Failed {
4516        message: String,
4517    },
4518    Cancelled,
4519}
4520
4521async fn async_run_status_handler(
4522    State(state): State<AppState>,
4523    AxumPath(wait_id): AxumPath<String>,
4524) -> Response {
4525    // wait_id comes from our own UUID generator; reject any structurally malformed value.
4526    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4527        return error::bad_request("invalid wait_id");
4528    }
4529    let run_state = {
4530        let runs = state.async_runs.lock().await;
4531        runs.get(&wait_id).cloned()
4532    };
4533    match run_state {
4534        None => error::not_found("run not found"),
4535        Some(AsyncRunState::Running {
4536            started_at,
4537            phase,
4538            files_done,
4539            files_total,
4540            ..
4541        }) => {
4542            // Treat runs older than 2 h as timed out (analysis should finish well under that).
4543            if started_at.elapsed() > std::time::Duration::from_hours(2) {
4544                let mut runs = state.async_runs.lock().await;
4545                runs.insert(
4546                    wait_id,
4547                    AsyncRunState::Failed {
4548                        message: "Analysis timed out after 2 hours.".to_string(),
4549                    },
4550                );
4551                drop(runs);
4552                return Json(AsyncRunStatusResponse::Failed {
4553                    message: "Analysis timed out after 2 hours.".to_string(),
4554                })
4555                .into_response();
4556            }
4557            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4558            Json(AsyncRunStatusResponse::Running {
4559                elapsed_secs: started_at.elapsed().as_secs(),
4560                phase: phase_str,
4561                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4562                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4563            })
4564            .into_response()
4565        }
4566        Some(AsyncRunState::Complete { run_id }) => {
4567            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4568        }
4569        Some(AsyncRunState::Failed { message }) => {
4570            Json(AsyncRunStatusResponse::Failed { message }).into_response()
4571        }
4572        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4573    }
4574}
4575
4576async fn cancel_run_handler(
4577    State(state): State<AppState>,
4578    AxumPath(wait_id): AxumPath<String>,
4579) -> Response {
4580    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4581        return error::bad_request("invalid wait_id");
4582    }
4583    let mut runs = state.async_runs.lock().await;
4584    let resp = match runs.get(&wait_id) {
4585        Some(AsyncRunState::Running { cancel_token, .. }) => {
4586            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4587            runs.insert(wait_id, AsyncRunState::Cancelled);
4588            StatusCode::OK.into_response()
4589        }
4590        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4591        _ => error::not_found("run not found"),
4592    };
4593    drop(runs);
4594    resp
4595}
4596
4597async fn async_run_result_handler(
4598    State(state): State<AppState>,
4599    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4600    AxumPath(run_id): AxumPath<String>,
4601) -> Response {
4602    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4603        return StatusCode::BAD_REQUEST.into_response();
4604    }
4605
4606    let artifacts = {
4607        let map = state.artifacts.lock().await;
4608        map.get(&run_id).cloned()
4609    };
4610    let artifacts = if let Some(a) = artifacts {
4611        a
4612    } else {
4613        let reg = state.registry.lock().await;
4614        if let Some(entry) = reg.find_by_run_id(&run_id) {
4615            recover_artifacts_from_registry(entry)
4616        } else {
4617            let html = ErrorTemplate {
4618                message: format!(
4619                    "Report not found. Run ID {} is not in the scan history.",
4620                    &run_id[..run_id.len().min(8)]
4621                ),
4622                last_report_url: Some("/view-reports".to_string()),
4623                last_report_label: Some("View Reports".to_string()),
4624                run_id: Some(run_id.clone()),
4625                error_code: Some(404),
4626                csp_nonce: csp_nonce.clone(),
4627                version: env!("CARGO_PKG_VERSION"),
4628            }
4629            .render()
4630            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4631            return (StatusCode::NOT_FOUND, Html(html)).into_response();
4632        }
4633    };
4634
4635    let json_path = if let Some(p) = &artifacts.json_path {
4636        p.clone()
4637    } else {
4638        let html = ErrorTemplate {
4639            message: "JSON result was not saved for this run.".to_string(),
4640            last_report_url: Some("/view-reports".to_string()),
4641            last_report_label: Some("View Reports".to_string()),
4642            run_id: Some(run_id.clone()),
4643            error_code: Some(404),
4644            csp_nonce: csp_nonce.clone(),
4645            version: env!("CARGO_PKG_VERSION"),
4646        }
4647        .render()
4648        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4649        return (StatusCode::NOT_FOUND, Html(html)).into_response();
4650    };
4651
4652    let Ok(run) = read_json(&json_path) else {
4653        let folder_hint = json_path
4654            .parent()
4655            .map(|p| p.display().to_string())
4656            .unwrap_or_default();
4657        let redirect_url = format!("/runs/result/{run_id}");
4658        return missing_scan_relocate_response(
4659            &format!(
4660                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4661                 deleted. Browse to the folder containing your scan output to reconnect it.",
4662                json_path.display()
4663            ),
4664            &run_id,
4665            &folder_hint,
4666            &redirect_url,
4667            state.server_mode,
4668            &csp_nonce,
4669        );
4670    };
4671
4672    let confluence_configured = {
4673        let store = state.confluence.lock().await;
4674        store.is_configured()
4675    };
4676
4677    render_result_page(
4678        &run,
4679        &artifacts,
4680        &run_id,
4681        &csp_nonce,
4682        confluence_configured,
4683        state.server_mode,
4684    )
4685}
4686
4687#[allow(clippy::too_many_lines)]
4688#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4689fn render_result_page(
4690    run: &AnalysisRun,
4691    artifacts: &RunArtifacts,
4692    run_id: &str,
4693    csp_nonce: &str,
4694    confluence_configured: bool,
4695    server_mode: bool,
4696) -> Response {
4697    let ctx = &artifacts.result_context;
4698    let prev_entry = &ctx.prev_entry;
4699    let prev_scan_count = ctx.prev_scan_count;
4700    let project_path = &ctx.project_path;
4701
4702    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4703        prev.json_path
4704            .as_ref()
4705            .and_then(|p| read_json(p).ok())
4706            .map(|prev_run| compute_delta(&prev_run, run))
4707    });
4708
4709    let files_analyzed = run.per_file_records.len() as u64;
4710    let files_skipped = run.skipped_file_records.len() as u64;
4711    let physical_lines = run
4712        .totals_by_language
4713        .iter()
4714        .map(|r| r.total_physical_lines)
4715        .sum::<u64>();
4716    let code_lines = run
4717        .totals_by_language
4718        .iter()
4719        .map(|r| r.code_lines)
4720        .sum::<u64>();
4721    let comment_lines = run
4722        .totals_by_language
4723        .iter()
4724        .map(|r| r.comment_lines)
4725        .sum::<u64>();
4726    let blank_lines = run
4727        .totals_by_language
4728        .iter()
4729        .map(|r| r.blank_lines)
4730        .sum::<u64>();
4731    let mixed_lines = run
4732        .totals_by_language
4733        .iter()
4734        .map(|r| r.mixed_lines_separate)
4735        .sum::<u64>();
4736    let functions = run
4737        .totals_by_language
4738        .iter()
4739        .map(|r| r.functions)
4740        .sum::<u64>();
4741    let classes = run
4742        .totals_by_language
4743        .iter()
4744        .map(|r| r.classes)
4745        .sum::<u64>();
4746    let variables = run
4747        .totals_by_language
4748        .iter()
4749        .map(|r| r.variables)
4750        .sum::<u64>();
4751    let imports = run
4752        .totals_by_language
4753        .iter()
4754        .map(|r| r.imports)
4755        .sum::<u64>();
4756
4757    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4758    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4759    let prev_fs = prev_sum.map(|s| s.files_skipped);
4760    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4761    let prev_cl = prev_sum.map(|s| s.code_lines);
4762    let prev_cml = prev_sum.map(|s| s.comment_lines);
4763    let prev_bl = prev_sum.map(|s| s.blank_lines);
4764    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4765    let prev_fa_str = fmt_prev(prev_fa);
4766    let prev_fs_str = fmt_prev(prev_fs);
4767    let prev_pl_str = fmt_prev(prev_pl);
4768    let prev_cl_str = fmt_prev(prev_cl);
4769    let prev_cml_str = fmt_prev(prev_cml);
4770    let prev_bl_str = fmt_prev(prev_bl);
4771    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4772    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4773    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4774    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4775    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4776    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4777    let delta_fa_class = delta_fa_class.to_string();
4778    let delta_fs_class = delta_fs_class.to_string();
4779    let delta_pl_class = delta_pl_class.to_string();
4780    let delta_cl_class = delta_cl_class.to_string();
4781    let delta_cml_class = delta_cml_class.to_string();
4782    let delta_bl_class = delta_bl_class.to_string();
4783
4784    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4785    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4786    let (delta_lines_net_str, delta_lines_net_class) =
4787        match (delta_lines_added, delta_lines_removed) {
4788            (Some(a), Some(r)) => {
4789                let net = a - r;
4790                (fmt_delta(net), delta_class(net).to_string())
4791            }
4792            _ => ("—".to_string(), "na".to_string()),
4793        };
4794
4795    let run_dir = artifacts.output_dir.clone();
4796    let git_branch = run.git_branch.clone();
4797    let git_commit = run.git_commit_short.clone();
4798    let git_commit_long = run.git_commit_long.clone();
4799    let git_author = run.git_commit_author.clone();
4800    let git_commit_url = run
4801        .git_remote_url
4802        .as_deref()
4803        .zip(run.git_commit_long.as_deref())
4804        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4805    let git_branch_url = run
4806        .git_remote_url
4807        .as_deref()
4808        .zip(run.git_branch.as_deref())
4809        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
4810    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4811        format!(
4812            "{} / {}",
4813            run.environment.initiator_username, run.environment.initiator_hostname
4814        )
4815    });
4816    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4817    let os_display = format!(
4818        "{} / {}",
4819        run.environment.operating_system, run.environment.architecture
4820    );
4821    let test_count = run.summary_totals.test_count;
4822
4823    let template = ResultTemplate {
4824        version: env!("CARGO_PKG_VERSION"),
4825        report_title: run.effective_configuration.reporting.report_title.clone(),
4826        project_path: project_path.clone(),
4827        output_dir: display_path(&artifacts.output_dir),
4828        run_id: run_id.to_owned(),
4829        run_id_short: run_id
4830            .split('-')
4831            .next_back()
4832            .unwrap_or(run_id)
4833            .chars()
4834            .take(7)
4835            .collect(),
4836        files_analyzed,
4837        files_skipped,
4838        physical_lines,
4839        code_lines,
4840        comment_lines,
4841        blank_lines,
4842        mixed_lines,
4843        functions,
4844        classes,
4845        variables,
4846        imports,
4847        html_url: artifacts
4848            .html_path
4849            .as_ref()
4850            .map(|_| format!("/runs/html/{run_id}")),
4851        pdf_url: artifacts
4852            .pdf_path
4853            .as_ref()
4854            .map(|_| format!("/runs/pdf/{run_id}")),
4855        json_url: artifacts
4856            .json_path
4857            .as_ref()
4858            .map(|_| format!("/runs/json/{run_id}")),
4859        html_download_url: artifacts
4860            .html_path
4861            .as_ref()
4862            .map(|_| format!("/runs/html/{run_id}?download=1")),
4863        pdf_download_url: artifacts
4864            .pdf_path
4865            .as_ref()
4866            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4867        json_download_url: artifacts
4868            .json_path
4869            .as_ref()
4870            .map(|_| format!("/runs/json/{run_id}?download=1")),
4871        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4872        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4873        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4874        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4875        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4876        prev_fa_str,
4877        prev_fs_str,
4878        prev_pl_str,
4879        prev_cl_str,
4880        prev_cml_str,
4881        prev_bl_str,
4882        delta_fa_str,
4883        delta_fa_class,
4884        delta_fs_str,
4885        delta_fs_class,
4886        delta_pl_str,
4887        delta_pl_class,
4888        delta_cl_str,
4889        delta_cl_class,
4890        delta_cml_str,
4891        delta_cml_class,
4892        delta_bl_str,
4893        delta_bl_class,
4894        delta_lines_added,
4895        delta_lines_removed,
4896        delta_lines_net_str,
4897        delta_lines_net_class,
4898        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4899        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4900        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4901        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4902        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4903            d.file_deltas
4904                .iter()
4905                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4906                .map(|f| {
4907                    #[allow(clippy::cast_sign_loss)]
4908                    let n = f.current_code as u64;
4909                    n
4910                })
4911                .sum()
4912        }),
4913        git_branch,
4914        git_branch_url,
4915        git_commit,
4916        git_commit_long,
4917        git_author,
4918        git_commit_url,
4919        scan_performed_by,
4920        scan_time_display,
4921        os_display,
4922        test_count,
4923        current_scan_number: prev_scan_count + 1,
4924        prev_scan_count,
4925        submodule_rows: run
4926            .submodule_summaries
4927            .iter()
4928            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
4929            .collect(),
4930        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4931        scan_config_url: format!("/runs/scan-config/{run_id}"),
4932        lang_chart_json: {
4933            let mut langs: Vec<&sloc_core::LanguageSummary> =
4934                run.totals_by_language.iter().collect();
4935            langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
4936            let entries: Vec<String> = langs
4937                .into_iter()
4938                .take(12)
4939                .map(|l| {
4940                    let name = l
4941                        .language
4942                        .display_name()
4943                        .replace('\\', "\\\\")
4944                        .replace('"', "\\\"");
4945                    format!(
4946                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4947                        name,
4948                        l.code_lines,
4949                        l.comment_lines,
4950                        l.blank_lines,
4951                        l.total_physical_lines,
4952                        l.functions,
4953                        l.classes,
4954                        l.variables,
4955                        l.imports,
4956                        l.files,
4957                    )
4958                })
4959                .collect();
4960            format!("[{}]", entries.join(","))
4961        },
4962        scatter_chart_json: {
4963            let entries: Vec<String> = run
4964                .totals_by_language
4965                .iter()
4966                .map(|l| {
4967                    let name = l
4968                        .language
4969                        .display_name()
4970                        .replace('\\', "\\\\")
4971                        .replace('"', "\\\"");
4972                    format!(
4973                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4974                        name, l.files, l.code_lines, l.total_physical_lines,
4975                    )
4976                })
4977                .collect();
4978            format!("[{}]", entries.join(","))
4979        },
4980        semantic_chart_json: {
4981            let entries: Vec<String> = run
4982                .totals_by_language
4983                .iter()
4984                .filter(|l| {
4985                    l.functions > 0
4986                        || l.classes > 0
4987                        || l.variables > 0
4988                        || l.imports > 0
4989                        || l.test_count > 0
4990                })
4991                .map(|l| {
4992                    let name = l
4993                        .language
4994                        .display_name()
4995                        .replace('\\', "\\\\")
4996                        .replace('"', "\\\"");
4997                    format!(
4998                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
4999                        name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5000                    )
5001                })
5002                .collect();
5003            format!("[{}]", entries.join(","))
5004        },
5005        submodule_chart_json: {
5006            let entries: Vec<String> = run
5007                .submodule_summaries
5008                .iter()
5009                .map(|s| {
5010                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5011                    format!(
5012                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5013                        name,
5014                        s.code_lines,
5015                        s.comment_lines,
5016                        s.blank_lines,
5017                        s.total_physical_lines,
5018                        s.files_analyzed,
5019                    )
5020                })
5021                .collect();
5022            format!("[{}]", entries.join(","))
5023        },
5024        has_submodule_data: !run.submodule_summaries.is_empty(),
5025        has_semantic_data: run
5026            .totals_by_language
5027            .iter()
5028            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5029        csp_nonce: csp_nonce.to_owned(),
5030        confluence_configured,
5031        server_mode,
5032        report_header_footer: run
5033            .effective_configuration
5034            .reporting
5035            .report_header_footer
5036            .clone(),
5037        is_offline: false,
5038    };
5039
5040    Html(
5041        template
5042            .render()
5043            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5044    )
5045    .into_response()
5046}
5047
5048fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5049    let slug: String = report_title
5050        .chars()
5051        .map(|c| {
5052            if c.is_alphanumeric() || c == '-' {
5053                c.to_ascii_lowercase()
5054            } else {
5055                '_'
5056            }
5057        })
5058        .collect::<String>()
5059        .split('_')
5060        .filter(|s| !s.is_empty())
5061        .collect::<Vec<_>>()
5062        .join("_");
5063
5064    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5065
5066    if slug.is_empty() {
5067        format!("report_{short_id}.pdf")
5068    } else {
5069        format!("{slug}_{short_id}.pdf")
5070    }
5071}
5072
5073#[derive(Serialize)]
5074struct PdfStatusResponse {
5075    ready: bool,
5076}
5077
5078/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
5079/// Clients poll this to update the button state without page reloads.
5080async fn pdf_status_handler(
5081    State(state): State<AppState>,
5082    AxumPath(run_id): AxumPath<String>,
5083) -> Response {
5084    let pdf_path = {
5085        let registry = state.artifacts.lock().await;
5086        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5087    };
5088    let pdf_path = if pdf_path.is_some() {
5089        pdf_path
5090    } else {
5091        let reg = state.registry.lock().await;
5092        reg.find_by_run_id(&run_id)
5093            .map(recover_artifacts_from_registry)
5094            .and_then(|a| a.pdf_path)
5095    };
5096    let ready = pdf_path.is_some_and(|p| p.exists());
5097    Json(PdfStatusResponse { ready }).into_response()
5098}
5099
5100/// GET /`api/runs/:run_id/bundle`
5101///
5102/// Streams a gzip-compressed tar archive containing every artifact in the run's
5103/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
5104/// is built in memory so it never touches a temp file.
5105async fn download_bundle_handler(
5106    State(state): State<AppState>,
5107    AxumPath(run_id): AxumPath<String>,
5108) -> Response {
5109    // Resolve output directory from in-memory cache or persisted registry.
5110    let output_dir = {
5111        let cache = state.artifacts.lock().await;
5112        cache.get(&run_id).map(|a| a.output_dir.clone())
5113    };
5114    let output_dir = if let Some(d) = output_dir {
5115        d
5116    } else {
5117        let reg = state.registry.lock().await;
5118        match reg.find_by_run_id(&run_id) {
5119            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5120            None => {
5121                return (
5122                    StatusCode::NOT_FOUND,
5123                    Json(serde_json::json!({"error": "Run not found"})),
5124                )
5125                    .into_response();
5126            }
5127        }
5128    };
5129
5130    if !output_dir.exists() {
5131        return (
5132            StatusCode::NOT_FOUND,
5133            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5134        )
5135            .into_response();
5136    }
5137
5138    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
5139    let run_id_clone = run_id.clone();
5140    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5141        use flate2::{write::GzEncoder, Compression};
5142        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5143        {
5144            let mut tar = tar::Builder::new(&mut enc);
5145            tar.follow_symlinks(false);
5146            // Append every regular file in the output directory, skipping
5147            // sub-directories (the output dir is always flat).
5148            if let Ok(entries) = std::fs::read_dir(&output_dir) {
5149                for entry in entries.filter_map(Result::ok) {
5150                    let p = entry.path();
5151                    if p.is_file() {
5152                        let name = p.file_name().unwrap_or_default().to_string_lossy();
5153                        let archive_path = format!("{run_id_clone}/{name}");
5154                        tar.append_path_with_name(&p, &archive_path)?;
5155                    }
5156                }
5157            }
5158            tar.finish()?;
5159        }
5160        Ok(enc.finish()?)
5161    })
5162    .await;
5163
5164    match archive_result {
5165        Ok(Ok(bytes)) => {
5166            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5167            axum::response::Response::builder()
5168                .status(StatusCode::OK)
5169                .header("Content-Type", "application/gzip")
5170                .header(
5171                    "Content-Disposition",
5172                    format!("attachment; filename=\"{filename}\""),
5173                )
5174                .header("Content-Length", bytes.len().to_string())
5175                .body(axum::body::Body::from(bytes))
5176                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5177        }
5178        Ok(Err(e)) => (
5179            StatusCode::INTERNAL_SERVER_ERROR,
5180            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5181        )
5182            .into_response(),
5183        Err(e) => (
5184            StatusCode::INTERNAL_SERVER_ERROR,
5185            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5186        )
5187            .into_response(),
5188    }
5189}
5190
5191/// DELETE /`api/runs/:run_id`
5192///
5193/// Removes all on-disk artifacts for the run and purges the run from the
5194/// in-memory cache and the persisted registry. Returns 204 on success.
5195async fn delete_run_handler(
5196    State(state): State<AppState>,
5197    AxumPath(run_id): AxumPath<String>,
5198) -> Response {
5199    // Resolve output directory.
5200    let output_dir = {
5201        let mut cache = state.artifacts.lock().await;
5202        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5203        cache.remove(&run_id);
5204        dir
5205    };
5206    let output_dir = if let Some(d) = output_dir {
5207        d
5208    } else {
5209        let reg = state.registry.lock().await;
5210        reg.find_by_run_id(&run_id)
5211            .map(|e| recover_artifacts_from_registry(e).output_dir)
5212            .unwrap_or_default()
5213    };
5214
5215    // Remove from persisted registry.
5216    {
5217        let mut reg = state.registry.lock().await;
5218        reg.entries.retain(|e| e.run_id != run_id);
5219        let _ = reg.save(&state.registry_path);
5220    }
5221
5222    // Delete on-disk artifacts.
5223    if output_dir.exists() {
5224        if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
5225            return (
5226                StatusCode::INTERNAL_SERVER_ERROR,
5227                Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5228            )
5229                .into_response();
5230        }
5231    }
5232
5233    StatusCode::NO_CONTENT.into_response()
5234}
5235
5236/// POST /api/runs/cleanup
5237///
5238/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
5239/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
5240async fn cleanup_runs_handler(
5241    State(state): State<AppState>,
5242    Json(body): Json<serde_json::Value>,
5243) -> Response {
5244    let days = body
5245        .get("older_than_days")
5246        .and_then(serde_json::Value::as_u64)
5247        .unwrap_or(30)
5248        .max(1);
5249
5250    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5251
5252    // Collect expired entries from the registry.
5253    let expired: Vec<(String, PathBuf)> = {
5254        let reg = state.registry.lock().await;
5255        reg.entries
5256            .iter()
5257            .filter(|e| e.timestamp_utc < cutoff)
5258            .map(|e| {
5259                let arts = recover_artifacts_from_registry(e);
5260                (e.run_id.clone(), arts.output_dir)
5261            })
5262            .collect()
5263    };
5264
5265    let mut deleted = 0usize;
5266    for (run_id, output_dir) in &expired {
5267        // Remove from in-memory cache.
5268        state.artifacts.lock().await.remove(run_id);
5269        // Delete on-disk artifacts (non-fatal if already gone).
5270        if output_dir.exists() {
5271            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5272                eprintln!(
5273                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5274                    output_dir.display()
5275                );
5276                continue;
5277            }
5278        }
5279        deleted += 1;
5280    }
5281
5282    // Purge expired run IDs from the registry in one pass.
5283    let expired_ids: std::collections::HashSet<&str> =
5284        expired.iter().map(|(id, _)| id.as_str()).collect();
5285    {
5286        let mut reg = state.registry.lock().await;
5287        reg.entries
5288            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5289        let _ = reg.save(&state.registry_path);
5290    }
5291
5292    Json(serde_json::json!({ "deleted": deleted })).into_response()
5293}
5294
5295/// Spawns the background auto-cleanup task. Returns a handle so the caller can
5296/// abort it when the policy is updated or disabled.
5297fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5298    tokio::spawn(async move {
5299        loop {
5300            let interval_secs = {
5301                let store = state.cleanup_policy.lock().await;
5302                match &store.policy {
5303                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5304                    _ => break,
5305                }
5306            };
5307            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5308            let n = run_auto_cleanup(&state).await;
5309            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5310        }
5311    })
5312}
5313
5314fn collect_runs_to_delete(
5315    reg: &ScanRegistry,
5316    max_age_days: Option<u32>,
5317    max_run_count: Option<u32>,
5318) -> std::collections::HashSet<String> {
5319    let mut to_delete = std::collections::HashSet::new();
5320    if let Some(days) = max_age_days {
5321        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5322        for e in &reg.entries {
5323            if e.timestamp_utc < cutoff {
5324                to_delete.insert(e.run_id.clone());
5325            }
5326        }
5327    }
5328    if let Some(max_count) = max_run_count {
5329        // entries are sorted newest-first; skip the ones we keep
5330        for e in reg.entries.iter().skip(max_count as usize) {
5331            to_delete.insert(e.run_id.clone());
5332        }
5333    }
5334    to_delete
5335}
5336
5337async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5338    let output_dir = {
5339        let mut cache = state.artifacts.lock().await;
5340        let d = cache.get(run_id).map(|a| a.output_dir.clone());
5341        cache.remove(run_id);
5342        d
5343    };
5344    let output_dir = if let Some(d) = output_dir {
5345        d
5346    } else {
5347        let reg = state.registry.lock().await;
5348        reg.find_by_run_id(run_id)
5349            .map(|e| recover_artifacts_from_registry(e).output_dir)
5350            .unwrap_or_default()
5351    };
5352    if output_dir.exists() {
5353        let _ = tokio::fs::remove_dir_all(&output_dir).await;
5354    }
5355}
5356
5357/// Core cleanup logic shared by the background task and the "Run Now" handler.
5358/// Applies both the age limit and the count limit, then updates `last_run_at`.
5359/// Returns the number of runs deleted.
5360async fn run_auto_cleanup(state: &AppState) -> u32 {
5361    let (max_age_days, max_run_count) = {
5362        let store = state.cleanup_policy.lock().await;
5363        match &store.policy {
5364            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5365            _ => return 0,
5366        }
5367    };
5368
5369    let to_delete = {
5370        let reg = state.registry.lock().await;
5371        collect_runs_to_delete(&reg, max_age_days, max_run_count)
5372    };
5373
5374    for run_id in &to_delete {
5375        delete_run_artifacts(state, run_id).await;
5376    }
5377
5378    // Purge from registry.
5379    if !to_delete.is_empty() {
5380        let mut reg = state.registry.lock().await;
5381        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5382        let _ = reg.save(&state.registry_path);
5383    }
5384
5385    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5386    {
5387        let mut store = state.cleanup_policy.lock().await;
5388        store.last_run_at = Some(chrono::Utc::now());
5389        store.last_run_deleted = Some(deleted);
5390        let _ = store.save(&state.cleanup_policy_path);
5391    }
5392    deleted
5393}
5394
5395// ── Auto-cleanup policy API ───────────────────────────────────────────────────
5396
5397/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
5398async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5399    let store = state.cleanup_policy.lock().await;
5400    Json(serde_json::json!({
5401        "policy": store.policy,
5402        "last_run_at": store.last_run_at,
5403        "last_run_deleted": store.last_run_deleted,
5404    }))
5405    .into_response()
5406}
5407
5408/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
5409async fn api_save_cleanup_policy(
5410    State(state): State<AppState>,
5411    Json(body): Json<CleanupPolicy>,
5412) -> Response {
5413    // Abort any running task so the new interval takes effect immediately.
5414    {
5415        let mut handle = state.cleanup_task_handle.lock().await;
5416        if let Some(h) = handle.take() {
5417            h.abort();
5418        }
5419    }
5420    {
5421        let mut store = state.cleanup_policy.lock().await;
5422        store.policy = Some(body.clone());
5423        if let Err(e) = store.save(&state.cleanup_policy_path) {
5424            return (
5425                StatusCode::INTERNAL_SERVER_ERROR,
5426                Json(serde_json::json!({"error": e.to_string()})),
5427            )
5428                .into_response();
5429        }
5430    }
5431    if body.enabled {
5432        let handle = spawn_cleanup_policy_task(state.clone());
5433        *state.cleanup_task_handle.lock().await = Some(handle);
5434    }
5435    StatusCode::NO_CONTENT.into_response()
5436}
5437
5438/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
5439async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5440    let deleted = run_auto_cleanup(&state).await;
5441    Json(serde_json::json!({ "deleted": deleted })).into_response()
5442}
5443
5444/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
5445async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5446    {
5447        let mut handle = state.cleanup_task_handle.lock().await;
5448        if let Some(h) = handle.take() {
5449            h.abort();
5450        }
5451    }
5452    {
5453        let mut store = state.cleanup_policy.lock().await;
5454        store.policy = None;
5455        let _ = store.save(&state.cleanup_policy_path);
5456    }
5457    StatusCode::NO_CONTENT.into_response()
5458}
5459
5460/// Serve the HTML artifact for a run — view or download.
5461/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
5462/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
5463/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
5464/// Only called for browser views; downloads keep the self-contained inline version.
5465fn swap_inline_chart_js_for_static(html: String) -> String {
5466    let Some(head_end) = html.find("</head>") else {
5467        return html;
5468    };
5469    let Some(script_start) = html[..head_end].rfind("<script") else {
5470        return html;
5471    };
5472    let Some(close_offset) = html[script_start..].find("</script>") else {
5473        return html;
5474    };
5475    let block_end = script_start + close_offset + "</script>".len();
5476    format!(
5477        "{}<script src=\"/static/chart-report.js\"></script>{}",
5478        &html[..script_start],
5479        &html[block_end..]
5480    )
5481}
5482
5483/// current-request Content-Security-Policy nonce check.
5484fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5485    // Find the first nonce value that was baked in at render time.
5486    let Some(start) = html.find("nonce=\"") else {
5487        // Reports generated before nonce support was added have bare <style> and <script>
5488        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
5489        // the inline blocks — without it the browser blocks all CSS and JS.
5490        return html
5491            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5492            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5493    };
5494    let value_start = start + 7; // len(r#"nonce=""#) == 7
5495    let Some(end_offset) = html[value_start..].find('"') else {
5496        return html.to_owned();
5497    };
5498    let old_nonce = &html[value_start..value_start + end_offset];
5499    html.replace(
5500        &format!("nonce=\"{old_nonce}\""),
5501        &format!("nonce=\"{new_nonce}\""),
5502    )
5503}
5504
5505fn serve_html_artifact(
5506    path: &Path,
5507    wants_download: bool,
5508    csp_nonce: &str,
5509    run_id: &str,
5510    server_mode: bool,
5511) -> Response {
5512    match fs::read_to_string(path) {
5513        Ok(raw) => {
5514            // Patch the saved nonce so inline styles/scripts pass CSP.
5515            let content = patch_html_nonce(&raw, csp_nonce);
5516            if wants_download {
5517                // Keep the self-contained inline version for downloads (opened as file://).
5518                (
5519                    [
5520                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5521                        (
5522                            header::CONTENT_DISPOSITION,
5523                            "attachment; filename=report.html",
5524                        ),
5525                    ],
5526                    content,
5527                )
5528                    .into_response()
5529            } else {
5530                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
5531                // browser caches it after the first view; the HTML response also shrinks.
5532                Html(swap_inline_chart_js_for_static(content)).into_response()
5533            }
5534        }
5535        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5536            let filename = path.file_name().map_or_else(
5537                || "report.html".to_string(),
5538                |n| n.to_string_lossy().into_owned(),
5539            );
5540            let html = LocateFileTemplate {
5541                run_id: run_id.to_owned(),
5542                artifact_type: "html".to_string(),
5543                expected_filename: filename,
5544                server_mode,
5545                csp_nonce: csp_nonce.to_owned(),
5546                version: env!("CARGO_PKG_VERSION"),
5547            }
5548            .render()
5549            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5550            (StatusCode::NOT_FOUND, Html(html)).into_response()
5551        }
5552        Err(err) => {
5553            let filename = path.file_name().map_or_else(
5554                || "report.html".to_string(),
5555                |n| n.to_string_lossy().into_owned(),
5556            );
5557            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5558            let html = ErrorTemplate {
5559                message: msg,
5560                last_report_url: Some("/view-reports".to_string()),
5561                last_report_label: Some("View Reports".to_string()),
5562                run_id: None,
5563                error_code: Some(404),
5564                csp_nonce: csp_nonce.to_owned(),
5565                version: env!("CARGO_PKG_VERSION"),
5566            }
5567            .render()
5568            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5569            (StatusCode::NOT_FOUND, Html(html)).into_response()
5570        }
5571    }
5572}
5573
5574/// Serve the PDF artifact for a run — inline or download.
5575fn serve_pdf_artifact(
5576    path: &Path,
5577    report_title: &str,
5578    run_id: &str,
5579    wants_download: bool,
5580    csp_nonce: &str,
5581) -> Response {
5582    match fs::read(path) {
5583        Ok(bytes) => {
5584            let filename = build_pdf_filename(report_title, run_id);
5585            let disposition = if wants_download {
5586                format!("attachment; filename=\"{filename}\"")
5587            } else {
5588                format!("inline; filename=\"{filename}\"")
5589            };
5590            (
5591                [
5592                    (header::CONTENT_TYPE, "application/pdf".to_string()),
5593                    (header::CONTENT_DISPOSITION, disposition),
5594                ],
5595                bytes,
5596            )
5597                .into_response()
5598        }
5599        Err(err) => {
5600            let filename = path.file_name().map_or_else(
5601                || "report.pdf".to_string(),
5602                |n| n.to_string_lossy().into_owned(),
5603            );
5604            let msg = format!(
5605                "PDF report '{filename}' could not be read.\n\n\
5606                 Error: {err}\n\n\
5607                 If you moved or renamed the output folder, the stored path is now stale. \
5608                 Use 'Open PDF folder' from the results page to browse the output directory."
5609            );
5610            let html = ErrorTemplate {
5611                message: msg,
5612                last_report_url: Some("/view-reports".to_string()),
5613                last_report_label: Some("View Reports".to_string()),
5614                run_id: Some(run_id.to_owned()),
5615                error_code: Some(404),
5616                csp_nonce: csp_nonce.to_owned(),
5617                version: env!("CARGO_PKG_VERSION"),
5618            }
5619            .render()
5620            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5621            (StatusCode::NOT_FOUND, Html(html)).into_response()
5622        }
5623    }
5624}
5625
5626/// Serve the JSON artifact for a run — view or download.
5627fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5628    match fs::read(path) {
5629        Ok(bytes) => {
5630            if wants_download {
5631                (
5632                    [
5633                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5634                        (
5635                            header::CONTENT_DISPOSITION,
5636                            "attachment; filename=result.json",
5637                        ),
5638                    ],
5639                    bytes,
5640                )
5641                    .into_response()
5642            } else {
5643                (
5644                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5645                    bytes,
5646                )
5647                    .into_response()
5648            }
5649        }
5650        Err(err) => {
5651            let filename = path.file_name().map_or_else(
5652                || "result.json".to_string(),
5653                |n| n.to_string_lossy().into_owned(),
5654            );
5655            let msg = format!(
5656                "JSON result '{filename}' could not be read.\n\n\
5657                 Error: {err}\n\n\
5658                 If you moved or renamed the output folder, the stored path is now stale. \
5659                 Use 'Open JSON folder' from the results page to browse the output directory."
5660            );
5661            let html = ErrorTemplate {
5662                message: msg,
5663                last_report_url: Some("/view-reports".to_string()),
5664                last_report_label: Some("View Reports".to_string()),
5665                run_id: None,
5666                error_code: Some(404),
5667                csp_nonce: csp_nonce.to_owned(),
5668                version: env!("CARGO_PKG_VERSION"),
5669            }
5670            .render()
5671            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5672            (StatusCode::NOT_FOUND, Html(html)).into_response()
5673        }
5674    }
5675}
5676
5677/// Recover a `RunArtifacts` from the persisted registry for a run ID.
5678fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5679    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
5680    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
5681    let output_dir = entry
5682        .html_path
5683        .as_ref()
5684        .or(entry.json_path.as_ref())
5685        .or(entry.pdf_path.as_ref())
5686        .or(entry.csv_path.as_ref())
5687        .or(entry.xlsx_path.as_ref())
5688        .and_then(|p| {
5689            let parent = p.parent()?;
5690            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5691            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
5692            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5693                parent.parent().map(PathBuf::from)
5694            } else {
5695                Some(parent.to_path_buf())
5696            }
5697        })
5698        .unwrap_or_default();
5699    // Recover pdf_path: use the persisted one, or look for report.pdf
5700    // adjacent to html/json if only the old entries lack it.
5701    let pdf_path = entry.pdf_path.clone().or_else(|| {
5702        let candidate = output_dir.join("report.pdf");
5703        candidate.exists().then_some(candidate)
5704    });
5705    // csv_path / xlsx_path: persisted paths take precedence; fall back to
5706    // scanning the run directory for files matching the expected patterns so
5707    // that runs created before this feature still surface their artifacts.
5708    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5709        // Check excel/ subfolder (new layout) then root (old layout).
5710        for dir in &[output_dir.join("excel"), output_dir.clone()] {
5711            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5712                entries
5713                    .filter_map(std::result::Result::ok)
5714                    .find(|e| {
5715                        let n = e.file_name();
5716                        let n = n.to_string_lossy();
5717                        n.starts_with("report_") && n.ends_with(ext)
5718                    })
5719                    .map(|e| e.path())
5720            }) {
5721                return Some(p);
5722            }
5723        }
5724        None
5725    };
5726
5727    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5728    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5729    RunArtifacts {
5730        output_dir: output_dir.clone(),
5731        html_path: entry.html_path.clone(),
5732        pdf_path,
5733        json_path: entry.json_path.clone(),
5734        csv_path,
5735        xlsx_path,
5736        scan_config_path: find_scan_config_in_dir(&output_dir),
5737        report_title: entry.project_label.clone(),
5738        result_context: RunResultContext::default(),
5739    }
5740}
5741
5742#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5743async fn resolve_artifact_set(
5744    state: &AppState,
5745    run_id: &str,
5746    csp_nonce: &str,
5747) -> Result<RunArtifacts, Response> {
5748    let cached = state.artifacts.lock().await.get(run_id).cloned();
5749    if let Some(a) = cached {
5750        return Ok(a);
5751    }
5752    let reg = state.registry.lock().await;
5753    if let Some(entry) = reg.find_by_run_id(run_id) {
5754        return Ok(recover_artifacts_from_registry(entry));
5755    }
5756    drop(reg);
5757    let short_id = &run_id[..run_id.len().min(8)];
5758    let hint = if matches!(
5759        run_id,
5760        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5761    ) {
5762        format!(
5763            " The URL format appears to be reversed — \
5764             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5765             Use the View Reports page to navigate to your scan."
5766        )
5767    } else {
5768        " The report may have been deleted or the report directory moved. \
5769         Use View Reports to browse your scan history."
5770            .to_string()
5771    };
5772    let error_html = ErrorTemplate {
5773        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5774        last_report_url: Some("/view-reports".to_string()),
5775        last_report_label: Some("View Reports".to_string()),
5776        run_id: None,
5777        error_code: Some(404),
5778        csp_nonce: csp_nonce.to_owned(),
5779        version: env!("CARGO_PKG_VERSION"),
5780    }
5781    .render()
5782    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5783    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5784}
5785
5786/// Return the path to a run's PDF, queuing background generation when it is missing.
5787///
5788/// Returns `Ok(path)` when the PDF is known (it may still be generating).
5789/// Returns `Err(response)` when there is no JSON source to regenerate from.
5790async fn resolve_or_queue_pdf(
5791    state: &AppState,
5792    pdf_path: Option<PathBuf>,
5793    json_path: Option<PathBuf>,
5794    output_dir: PathBuf,
5795    run_id: &str,
5796    report_title: &str,
5797    csp_nonce: &str,
5798) -> Result<PathBuf, Response> {
5799    if let Some(p) = pdf_path {
5800        return Ok(p);
5801    }
5802    let Some(json_src) = json_path.filter(|p| p.exists()) else {
5803        let msg = "PDF report was not generated for this run. \
5804                   Re-run the analysis with PDF output enabled."
5805            .to_string();
5806        let html = ErrorTemplate {
5807            message: msg,
5808            last_report_url: Some(format!("/runs/html/{run_id}")),
5809            last_report_label: Some("View HTML Report".to_string()),
5810            run_id: Some(run_id.to_string()),
5811            error_code: Some(404),
5812            csp_nonce: csp_nonce.to_string(),
5813            version: env!("CARGO_PKG_VERSION"),
5814        }
5815        .render()
5816        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5817        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5818    };
5819    let pdf_filename = build_pdf_filename(report_title, run_id);
5820    let pdf_dest = output_dir.join(&pdf_filename);
5821    if !pdf_dest.exists() {
5822        // Record the pending path so concurrent requests show the spinner.
5823        {
5824            let mut map = state.artifacts.lock().await;
5825            if let Some(entry) = map.get_mut(run_id) {
5826                entry.pdf_path = Some(pdf_dest.clone());
5827            }
5828        }
5829        {
5830            let mut reg = state.registry.lock().await;
5831            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5832                e.pdf_path = Some(pdf_dest.clone());
5833            }
5834            let _ = reg.save(&state.registry_path);
5835        }
5836        spawn_native_pdf_background(
5837            json_src,
5838            pdf_dest.clone(),
5839            run_id.to_string(),
5840            state.artifacts.clone(),
5841        );
5842    }
5843    Ok(pdf_dest)
5844}
5845
5846/// Self-refreshing "please wait" page shown while the background PDF task is still running.
5847fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5848    let html = format!(
5849                    "<!doctype html><html lang=\"en\"><head>\
5850                     <meta charset=utf-8>\
5851                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5852                     <meta http-equiv=\"refresh\" content=\"5\">\
5853                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
5854                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5855                     <style nonce=\"{csp_nonce}\">\
5856                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5857                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5858                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5859                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5860                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5861                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5862                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5863                     background:var(--bg);color:var(--text);}}\
5864                     .top-nav{{position:sticky;top:0;z-index:30;\
5865                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5866                     border-bottom:1px solid rgba(255,255,255,0.12);\
5867                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5868                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5869                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
5870                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5871                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5872                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5873                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5874                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5875                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5876                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5877                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5878                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5879                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5880                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5881                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5882                     justify-content:center;min-height:38px;border-radius:999px;\
5883                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5884                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5885                     .theme-toggle .icon-sun{{display:none;}}\
5886                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5887                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5888                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5889                     display:flex;align-items:center;justify-content:center;\
5890                     min-height:calc(100vh - 56px);}}\
5891                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
5892                     .panel{{background:var(--surface);border:1px solid var(--line);\
5893                     border-radius:var(--radius);box-shadow:var(--shadow);\
5894                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5895                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
5896                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
5897                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5898                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5899                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5900                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5901                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5902                     min-height:42px;padding:0 20px;border-radius:14px;\
5903                     border:1px solid var(--line-strong);text-decoration:none;\
5904                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5905                     .back-link:hover{{background:var(--line);}}\
5906                     </style></head>\
5907                     <body>\
5908                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5909                       <a class=\"brand\" href=\"/\">\
5910                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5911                         <div class=\"brand-copy\">\
5912                           <div class=\"brand-title\">OxideSLOC</div>\
5913                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5914                         </div>\
5915                       </a>\
5916                       <div class=\"nav-right\">\
5917                         <a class=\"nav-pill\" href=\"/\">Home</a>\
5918                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5919                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5920                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5921                           <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>\
5922                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5923                           <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>\
5924                         </button>\
5925                       </div>\
5926                     </div></div>\
5927                     <div class=\"page\"><div class=\"panel\">\
5928                       <div class=\"spin-ring\"></div>\
5929                       <h1>Generating PDF\u{2026}</h1>\
5930                       <p>The PDF is being generated from the scan results.<br>\
5931                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
5932                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5933                     </div></div>\
5934                     <script nonce=\"{csp_nonce}\">\
5935                     (function(){{\
5936                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5937                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
5938                       var t=document.getElementById(\"theme-toggle\");\
5939                       if(t)t.addEventListener(\"click\",function(){{\
5940                         var d=b.classList.toggle(\"dark-theme\");\
5941                         localStorage.setItem(k,d?\"dark\":\"light\");\
5942                       }});\
5943                     }})();\
5944                     </script>\
5945                     </body></html>"
5946    );
5947    Html(html).into_response()
5948}
5949
5950/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
5951fn render_error_artifact_html(
5952    message: String,
5953    last_report_url: Option<String>,
5954    last_report_label: Option<String>,
5955    run_id: Option<String>,
5956    error_code: Option<u16>,
5957    csp_nonce: &str,
5958) -> String {
5959    ErrorTemplate {
5960        message,
5961        last_report_url,
5962        last_report_label,
5963        run_id,
5964        error_code,
5965        csp_nonce: csp_nonce.to_owned(),
5966        version: env!("CARGO_PKG_VERSION"),
5967    }
5968    .render()
5969    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
5970}
5971
5972/// Read a file and serve it as an attachment download.
5973fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
5974    fs::read(path).map_or_else(
5975        |_| StatusCode::NOT_FOUND.into_response(),
5976        |bytes| {
5977            let filename = path.file_name().map_or_else(
5978                || fallback_filename.to_string(),
5979                |n| n.to_string_lossy().into_owned(),
5980            );
5981            (
5982                [
5983                    (header::CONTENT_TYPE, content_type.to_string()),
5984                    (
5985                        header::CONTENT_DISPOSITION,
5986                        format!("attachment; filename=\"{filename}\""),
5987                    ),
5988                ],
5989                bytes,
5990            )
5991                .into_response()
5992        },
5993    )
5994}
5995
5996fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5997    let Some(path) = csv_path else {
5998        let html = render_error_artifact_html(
5999            "CSV report was not generated for this run, or was not recorded in \
6000             the scan registry."
6001                .to_string(),
6002            Some(format!("/runs/html/{run_id}")),
6003            Some("View HTML Report".to_string()),
6004            Some(run_id.to_string()),
6005            Some(404),
6006            csp_nonce,
6007        );
6008        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6009    };
6010    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6011}
6012
6013fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6014    let Some(path) = xlsx_path else {
6015        let html = render_error_artifact_html(
6016            "Excel report was not generated for this run, or was not recorded in \
6017             the scan registry."
6018                .to_string(),
6019            Some(format!("/runs/html/{run_id}")),
6020            Some("View HTML Report".to_string()),
6021            Some(run_id.to_string()),
6022            Some(404),
6023            csp_nonce,
6024        );
6025        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6026    };
6027    serve_binary_download(
6028        &path,
6029        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6030        "report.xlsx",
6031    )
6032}
6033
6034fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6035    let path = artifact_set
6036        .scan_config_path
6037        .as_deref()
6038        .map(std::path::Path::to_path_buf)
6039        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6040        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6041    fs::read(&path).map_or_else(
6042        |_| StatusCode::NOT_FOUND.into_response(),
6043        |bytes| {
6044            (
6045                [
6046                    (
6047                        header::CONTENT_TYPE,
6048                        "application/json; charset=utf-8".to_string(),
6049                    ),
6050                    (
6051                        header::CONTENT_DISPOSITION,
6052                        "attachment; filename=\"scan-config.json\"".to_string(),
6053                    ),
6054                ],
6055                bytes,
6056            )
6057                .into_response()
6058        },
6059    )
6060}
6061
6062/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
6063/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
6064/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
6065/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
6066async fn serve_submodule_pdf_arm(
6067    artifact: &str,
6068    artifact_set: RunArtifacts,
6069    wants_download: bool,
6070    run_id: &str,
6071    csp_nonce: &str,
6072) -> Response {
6073    // "sub_benchmark_pdf" → base = "sub_benchmark"
6074    let base = artifact.trim_end_matches("_pdf");
6075    let sub_dir = artifact_set.output_dir.join("submodules");
6076    let pdf_path = sub_dir.join(format!("{base}.pdf"));
6077
6078    if !pdf_path.exists() {
6079        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
6080        let derived_safe = base.trim_start_matches("sub_");
6081        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6082            let parent_run = read_json(jp).ok()?;
6083            let sub = parent_run
6084                .submodule_summaries
6085                .iter()
6086                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6087                .clone();
6088            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6089            Some((parent_run, sub, parent_path))
6090        });
6091
6092        if let Some((parent_run, sub, parent_path)) = rebuilt {
6093            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6094            let pp = pdf_path.clone();
6095            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6096        }
6097    }
6098
6099    if !pdf_path.exists() {
6100        let html = render_error_artifact_html(
6101            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6102             enabled."
6103                .to_string(),
6104            Some("/view-reports".to_string()),
6105            Some("View Reports".to_string()),
6106            Some(run_id.to_string()),
6107            Some(404),
6108            csp_nonce,
6109        );
6110        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6111    }
6112
6113    serve_pdf_artifact(
6114        &pdf_path,
6115        &artifact_set.report_title,
6116        run_id,
6117        wants_download,
6118        csp_nonce,
6119    )
6120}
6121
6122fn serve_submodule_arm(
6123    artifact: &str,
6124    artifact_set: &RunArtifacts,
6125    wants_download: bool,
6126    csp_nonce: &str,
6127    run_id: &str,
6128    server_mode: bool,
6129) -> Response {
6130    if artifact.len() > 128
6131        || !artifact
6132            .chars()
6133            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6134    {
6135        return StatusCode::BAD_REQUEST.into_response();
6136    }
6137    let filename = format!("{artifact}.html");
6138    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
6139    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6140    let path = if new_layout.exists() {
6141        new_layout
6142    } else {
6143        artifact_set.output_dir.join(&filename)
6144    };
6145    if !path.exists() {
6146        let html = render_error_artifact_html(
6147            format!(
6148                "Sub-report '{artifact}' was not found in the run directory.\n\
6149                 Re-run the analysis with 'Detect and separate git submodules' \
6150                 and HTML output enabled."
6151            ),
6152            Some("/view-reports".to_string()),
6153            Some("View Reports".to_string()),
6154            Some(run_id.to_string()),
6155            Some(404),
6156            csp_nonce,
6157        );
6158        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6159    }
6160    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6161}
6162
6163async fn serve_pdf_arm(
6164    state: &AppState,
6165    artifact_set: RunArtifacts,
6166    wants_download: bool,
6167    run_id: &str,
6168    csp_nonce: &str,
6169) -> Response {
6170    let report_title = artifact_set.report_title.clone();
6171    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6172    let stale_html_name = artifact_set
6173        .html_path
6174        .as_deref()
6175        .and_then(|p| p.file_name())
6176        .map(|n| n.to_string_lossy().into_owned());
6177    let path = match resolve_or_queue_pdf(
6178        state,
6179        artifact_set.pdf_path,
6180        artifact_set.json_path.clone(),
6181        artifact_set.output_dir.clone(),
6182        run_id,
6183        &report_title,
6184        csp_nonce,
6185    )
6186    .await
6187    {
6188        Ok(p) => p,
6189        Err(r) => return r,
6190    };
6191    if !path.exists() {
6192        // Distinguish a stale registry path (folder moved) from an in-progress
6193        // background generation. Only show the locate page when the PDF was
6194        // already recorded in the registry but the file is now missing.
6195        if had_pdf_in_registry {
6196            if let Some(expected_filename) = stale_html_name {
6197                let html = LocateFileTemplate {
6198                    run_id: run_id.to_string(),
6199                    artifact_type: "pdf".to_string(),
6200                    expected_filename,
6201                    server_mode: state.server_mode,
6202                    csp_nonce: csp_nonce.to_string(),
6203                    version: env!("CARGO_PKG_VERSION"),
6204                }
6205                .render()
6206                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6207                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6208            }
6209        }
6210        return pdf_generating_response(run_id, csp_nonce);
6211    }
6212    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6213}
6214
6215async fn artifact_handler(
6216    State(state): State<AppState>,
6217    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6218    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6219    Query(query): Query<ArtifactQuery>,
6220) -> Response {
6221    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6222        Ok(a) => a,
6223        Err(r) => return r,
6224    };
6225
6226    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6227
6228    match artifact.as_str() {
6229        "html" => {
6230            let Some(path) = artifact_set.html_path else {
6231                return StatusCode::NOT_FOUND.into_response();
6232            };
6233            serve_html_artifact(
6234                &path,
6235                wants_download,
6236                &csp_nonce,
6237                &run_id,
6238                state.server_mode,
6239            )
6240        }
6241        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6242        "json" => {
6243            let Some(path) = artifact_set.json_path else {
6244                let html = render_error_artifact_html(
6245                    "JSON result was not generated for this run, or was not recorded in \
6246                     the scan registry. Re-run the analysis with JSON output enabled."
6247                        .to_string(),
6248                    Some("/view-reports".to_string()),
6249                    Some("View Reports".to_string()),
6250                    Some(run_id.clone()),
6251                    Some(404),
6252                    &csp_nonce,
6253                );
6254                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6255            };
6256            serve_json_artifact(&path, wants_download, &csp_nonce)
6257        }
6258        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6259        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6260        "scan-config" => serve_scan_config_arm(&artifact_set),
6261        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6262            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6263                .await
6264        }
6265        _ if artifact.starts_with("sub_") => serve_submodule_arm(
6266            &artifact,
6267            &artifact_set,
6268            wants_download,
6269            &csp_nonce,
6270            &run_id,
6271            state.server_mode,
6272        ),
6273        _ => StatusCode::NOT_FOUND.into_response(),
6274    }
6275}
6276
6277// ── History ───────────────────────────────────────────────────────────────────
6278
6279struct SubmoduleLinkRow {
6280    name: String,
6281    url: String,
6282}
6283
6284struct HistoryEntryRow {
6285    run_id: String,
6286    run_id_short: String,
6287    timestamp: String,
6288    timestamp_utc_ms: i64,
6289    project_label: String,
6290    project_path: String,
6291    files_analyzed: u64,
6292    files_skipped: u64,
6293    code_lines: u64,
6294    comment_lines: u64,
6295    blank_lines: u64,
6296    git_branch: String,
6297    git_commit: String,
6298    has_html: bool,
6299    has_json: bool,
6300    has_pdf: bool,
6301    submodule_links: Vec<SubmoduleLinkRow>,
6302    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
6303    submodule_names_csv: String,
6304}
6305
6306/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
6307fn nth_weekday_of_month(
6308    year: i32,
6309    month: u32,
6310    weekday: chrono::Weekday,
6311    n: u32,
6312) -> chrono::NaiveDate {
6313    use chrono::Datelike;
6314    let mut count = 0u32;
6315    let mut day = 1u32;
6316    loop {
6317        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6318        if d.weekday() == weekday {
6319            count += 1;
6320            if count == n {
6321                return d;
6322            }
6323        }
6324        day += 1;
6325    }
6326}
6327
6328/// Returns true if `dt` falls within US Pacific Daylight Time.
6329/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
6330/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
6331fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6332    use chrono::{Datelike, TimeZone};
6333    let year = dt.year();
6334    let dst_start = chrono::Utc.from_utc_datetime(
6335        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6336            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6337    );
6338    let dst_end = chrono::Utc.from_utc_datetime(
6339        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6340            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6341    );
6342    dt >= dst_start && dt < dst_end
6343}
6344
6345fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6346    if is_pacific_dst(dt) {
6347        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6348            .format("%Y-%m-%d %H:%M PDT")
6349            .to_string()
6350    } else {
6351        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6352            .format("%Y-%m-%d %H:%M PST")
6353            .to_string()
6354    }
6355}
6356
6357/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
6358fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6359    let (offset, tz) = if is_pacific_dst(dt) {
6360        (
6361            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6362            "PDT",
6363        )
6364    } else {
6365        (
6366            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6367            "PST",
6368        )
6369    };
6370    format!(
6371        "{} {tz}",
6372        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6373    )
6374}
6375
6376fn fmt_git_date(iso: &str) -> Option<String> {
6377    chrono::DateTime::parse_from_rfc3339(iso)
6378        .ok()
6379        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6380}
6381
6382fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6383    reg.entries
6384        .iter()
6385        .map(|e| {
6386            let submodule_links = {
6387                let mut links: Vec<SubmoduleLinkRow> = vec![];
6388                let sub_dir = e
6389                    .html_path
6390                    .as_ref()
6391                    .and_then(|p| p.parent())
6392                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6393                if let Some(dir) = sub_dir {
6394                    if let Ok(rd) = std::fs::read_dir(dir) {
6395                        for entry_res in rd.flatten() {
6396                            let fname = entry_res.file_name();
6397                            let fname_str = fname.to_string_lossy();
6398                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6399                                let stem = &fname_str[..fname_str.len() - 5];
6400                                let display = stem[4..].replace('-', " ");
6401                                links.push(SubmoduleLinkRow {
6402                                    name: display,
6403                                    url: format!("/runs/{stem}/{}", e.run_id),
6404                                });
6405                            }
6406                        }
6407                    }
6408                }
6409                links.sort_by(|a, b| a.name.cmp(&b.name));
6410                links
6411            };
6412            let submodule_names_csv = submodule_links
6413                .iter()
6414                .map(|l| l.name.as_str())
6415                .collect::<Vec<_>>()
6416                .join(",");
6417            HistoryEntryRow {
6418                run_id: e.run_id.clone(),
6419                run_id_short: e
6420                    .run_id
6421                    .split('-')
6422                    .next_back()
6423                    .unwrap_or(&e.run_id)
6424                    .chars()
6425                    .take(7)
6426                    .collect(),
6427                timestamp: fmt_la_time(e.timestamp_utc),
6428                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6429                project_label: e.project_label.clone(),
6430                project_path: e
6431                    .input_roots
6432                    .first()
6433                    .map(|s| sanitize_path_str(s))
6434                    .unwrap_or_default(),
6435                files_analyzed: e.summary.files_analyzed,
6436                files_skipped: e.summary.files_skipped,
6437                code_lines: e.summary.code_lines,
6438                comment_lines: e.summary.comment_lines,
6439                blank_lines: e.summary.blank_lines,
6440                git_branch: e.git_branch.clone().unwrap_or_default(),
6441                git_commit: e.git_commit.clone().unwrap_or_default(),
6442                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6443                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6444                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6445                submodule_links,
6446                submodule_names_csv,
6447            }
6448        })
6449        .collect()
6450}
6451
6452#[derive(Deserialize, Default)]
6453struct HistoryQuery {
6454    linked: Option<String>,
6455    error: Option<String>,
6456}
6457
6458async fn history_handler(
6459    State(state): State<AppState>,
6460    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6461    Query(query): Query<HistoryQuery>,
6462) -> impl IntoResponse {
6463    // Auto-scan all watched directories before rendering so the list stays fresh.
6464    auto_scan_watched_dirs(&state).await;
6465    let watched_dirs: Vec<String> = {
6466        let wd = state.watched_dirs.lock().await;
6467        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6468    };
6469    let mut entries = {
6470        let reg = state.registry.lock().await;
6471        make_history_rows(&reg)
6472    };
6473    entries.retain(|e| e.has_html);
6474    let total_scans = entries.len();
6475    let linked_count = query
6476        .linked
6477        .as_deref()
6478        .and_then(|s| s.parse::<usize>().ok())
6479        .unwrap_or(0);
6480    let browse_error = query.error.filter(|s| !s.is_empty());
6481    let template = HistoryTemplate {
6482        version: env!("CARGO_PKG_VERSION"),
6483        entries,
6484        total_scans,
6485        linked_count,
6486        browse_error,
6487        watched_dirs,
6488        csp_nonce,
6489        server_mode: state.server_mode,
6490    };
6491    Html(
6492        template
6493            .render()
6494            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6495    )
6496    .into_response()
6497}
6498
6499async fn compare_select_handler(
6500    State(state): State<AppState>,
6501    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6502) -> impl IntoResponse {
6503    auto_scan_watched_dirs(&state).await;
6504    let watched_dirs: Vec<String> = {
6505        let wd = state.watched_dirs.lock().await;
6506        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6507    };
6508    let mut entries = {
6509        let reg = state.registry.lock().await;
6510        make_history_rows(&reg)
6511    };
6512    entries.retain(|e| e.has_json);
6513    let total_scans = entries.len();
6514    let template = CompareSelectTemplate {
6515        version: env!("CARGO_PKG_VERSION"),
6516        entries,
6517        total_scans,
6518        watched_dirs,
6519        csp_nonce,
6520        server_mode: state.server_mode,
6521    };
6522    Html(
6523        template
6524            .render()
6525            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6526    )
6527    .into_response()
6528}
6529
6530// ── Compare ───────────────────────────────────────────────────────────────────
6531
6532#[derive(Deserialize, Default)]
6533struct CompareQuery {
6534    a: Option<String>,
6535    b: Option<String>,
6536    /// Optional submodule name to scope the comparison to one submodule.
6537    sub: Option<String>,
6538    /// "super" to exclude all submodule files and show only the super-repo.
6539    scope: Option<String>,
6540}
6541
6542struct CompareFileDeltaRow {
6543    relative_path: String,
6544    language: String,
6545    status: String,
6546    baseline_code: i64,
6547    current_code: i64,
6548    code_delta_str: String,
6549    code_delta_class: String,
6550    comment_delta_str: String,
6551    comment_delta_class: String,
6552    total_delta_str: String,
6553    total_delta_class: String,
6554}
6555
6556/// Recompute `summary_totals` from the current `per_file_records` slice.
6557/// Used when `per_file_records` has been narrowed to a submodule subset.
6558fn recompute_summary_from_records(run: &mut AnalysisRun) {
6559    let mut totals = SummaryTotals::default();
6560    for r in &run.per_file_records {
6561        if r.language.is_some() {
6562            totals.files_analyzed += 1;
6563        }
6564        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6565        totals.code_lines += r.effective_counts.code_lines;
6566        totals.comment_lines += r.effective_counts.comment_lines;
6567        totals.blank_lines += r.effective_counts.blank_lines;
6568        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6569        totals.functions += r.raw_line_categories.functions;
6570        totals.classes += r.raw_line_categories.classes;
6571        totals.variables += r.raw_line_categories.variables;
6572        totals.imports += r.raw_line_categories.imports;
6573        totals.test_count += r.raw_line_categories.test_count;
6574        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6575        totals.test_suite_count += r.raw_line_categories.test_suite_count;
6576        if let Some(cov) = &r.coverage {
6577            totals.coverage_lines_found += u64::from(cov.lines_found);
6578            totals.coverage_lines_hit += u64::from(cov.lines_hit);
6579            totals.coverage_functions_found += u64::from(cov.functions_found);
6580            totals.coverage_functions_hit += u64::from(cov.functions_hit);
6581            totals.coverage_branches_found += u64::from(cov.branches_found);
6582            totals.coverage_branches_hit += u64::from(cov.branches_hit);
6583        }
6584    }
6585    totals.files_considered = totals.files_analyzed;
6586    run.summary_totals = totals;
6587}
6588
6589fn fmt_delta(n: i64) -> String {
6590    if n > 0 {
6591        format!("+{n}")
6592    } else {
6593        format!("{n}")
6594    }
6595}
6596
6597fn delta_class(n: i64) -> &'static str {
6598    use std::cmp::Ordering;
6599    match n.cmp(&0) {
6600        Ordering::Greater => "pos",
6601        Ordering::Less => "neg",
6602        Ordering::Equal => "zero",
6603    }
6604}
6605
6606// ratio/percentage display, precision loss acceptable
6607#[allow(clippy::cast_precision_loss)]
6608fn fmt_pct(delta: i64, baseline: u64) -> String {
6609    if baseline == 0 {
6610        return "—".to_string();
6611    }
6612    #[allow(clippy::cast_precision_loss)]
6613    let pct = (delta as f64 / baseline as f64) * 100.0;
6614    if pct > 0.049 {
6615        format!("+{pct:.1}%")
6616    } else if pct < -0.049 {
6617        format!("{pct:.1}%")
6618    } else {
6619        "±0%".to_string()
6620    }
6621}
6622
6623/// Returns (`display_string`, `css_class`) for a numeric change column cell.
6624fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6625    prev.map_or_else(
6626        || ("—".to_string(), "na"),
6627        |p| {
6628            #[allow(clippy::cast_possible_wrap)]
6629            let d = curr as i64 - p as i64;
6630            (fmt_delta(d), delta_class(d))
6631        },
6632    )
6633}
6634
6635#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
6636fn load_scan_for_compare(
6637    json_path: &std::path::Path,
6638    scan_label: &str,
6639    run_id: &str,
6640    server_mode: bool,
6641    compare_url: &str,
6642    csp_nonce: &str,
6643) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6644    match read_json(json_path) {
6645        Ok(r) => Ok(r),
6646        Err(e) => {
6647            if server_mode {
6648                let html = ErrorTemplate {
6649                    message: format!(
6650                        "Could not load {scan_label} scan data. The scan output folder may have \
6651                         been moved, renamed, or deleted. Re-running the analysis will create \
6652                         fresh comparison data."
6653                    ),
6654                    last_report_url: Some("/compare-scans".to_string()),
6655                    last_report_label: Some("Compare Scans".to_string()),
6656                    run_id: Some(run_id.to_owned()),
6657                    error_code: Some(404),
6658                    csp_nonce: csp_nonce.to_owned(),
6659                    version: env!("CARGO_PKG_VERSION"),
6660                }
6661                .render()
6662                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6663                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6664            }
6665            let msg = format!(
6666                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6667                json_path.display()
6668            );
6669            let folder_hint = json_path
6670                .parent()
6671                .map(|p| p.display().to_string())
6672                .unwrap_or_default();
6673            Err(missing_scan_relocate_response(
6674                &msg,
6675                run_id,
6676                &folder_hint,
6677                compare_url,
6678                false,
6679                csp_nonce,
6680            ))
6681        }
6682    }
6683}
6684
6685struct ChurnStats {
6686    new_scope: bool,
6687    scope_flag: bool,
6688    churn_rate_str: String,
6689    churn_rate_class: String,
6690}
6691
6692fn compute_churn_stats(
6693    baseline_code: u64,
6694    current_code: u64,
6695    lines_added: i64,
6696    lines_removed: i64,
6697) -> ChurnStats {
6698    let new_scope = baseline_code == 0 && current_code > 0;
6699    #[allow(clippy::cast_precision_loss)]
6700    let churn_pct = if baseline_code > 0 {
6701        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6702    } else {
6703        0.0
6704    };
6705    #[allow(clippy::cast_precision_loss)]
6706    let scope_flag =
6707        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6708    let churn_rate_str = if new_scope {
6709        "New".to_string()
6710    } else if baseline_code > 0 {
6711        format!("{churn_pct:.1}%")
6712    } else {
6713        "—".to_string()
6714    };
6715    let churn_rate_class = if new_scope || churn_pct > 20.0 {
6716        "high".to_string()
6717    } else if churn_pct > 5.0 {
6718        "med".to_string()
6719    } else {
6720        "low".to_string()
6721    };
6722    ChurnStats {
6723        new_scope,
6724        scope_flag,
6725        churn_rate_str,
6726        churn_rate_class,
6727    }
6728}
6729
6730/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
6731/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
6732/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
6733fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
6734    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
6735    if !has_data {
6736        return String::new();
6737    }
6738    let base_str = s
6739        .baseline_coverage_line_pct
6740        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
6741    let curr_str = s
6742        .current_coverage_line_pct
6743        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
6744    let (delta_str, cls) = match s.coverage_line_pct_delta {
6745        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
6746        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
6747        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
6748        None => ("\u{2014}".into(), "zero"),
6749    };
6750    format!(
6751        r#"<div class="delta-card">
6752          <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo. Positive delta = more lines instrumented and hit. Only shown when at least one scan has coverage data.</div>
6753          <div class="delta-card-label">Line coverage</div>
6754          <div class="delta-card-from">Before: {base_str}</div>
6755          <div class="delta-card-to">{curr_str}</div>
6756          <span class="delta-card-change {cls}">{delta_str}</span>
6757        </div>"#
6758    )
6759}
6760
6761#[allow(clippy::too_many_lines)]
6762async fn compare_handler(
6763    State(state): State<AppState>,
6764    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6765    Query(query): Query<CompareQuery>,
6766) -> impl IntoResponse {
6767    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
6768    // redirect to the history page where the user can select two runs.
6769    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
6770        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
6771        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
6772    };
6773
6774    let (maybe_a, maybe_b) = {
6775        let reg = state.registry.lock().await;
6776        (
6777            reg.find_by_run_id(&run_id_a).cloned(),
6778            reg.find_by_run_id(&run_id_b).cloned(),
6779        )
6780    };
6781
6782    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
6783        let html = ErrorTemplate {
6784            message: "One or both run IDs were not found in scan history. \
6785                      The runs may have been deleted or the registry may have been reset."
6786                .to_string(),
6787            last_report_url: Some("/compare-scans".to_string()),
6788            last_report_label: Some("Compare Scans".to_string()),
6789            run_id: None,
6790            error_code: None,
6791            csp_nonce: csp_nonce.clone(),
6792            version: env!("CARGO_PKG_VERSION"),
6793        }
6794        .render()
6795        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
6796        return Html(html).into_response();
6797    };
6798
6799    // Ensure older scan is always the baseline.
6800    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
6801        (entry_a, entry_b)
6802    } else {
6803        (entry_b, entry_a)
6804    };
6805
6806    // If query params were in the wrong order, redirect to canonical URL so the
6807    // browser always shows the same URL for the same two scans regardless of how
6808    // the user arrived here (Full diff button vs. Compare Scans selection).
6809    if baseline_entry.run_id != run_id_a {
6810        let canonical = format!(
6811            "/compare?a={}&b={}",
6812            baseline_entry.run_id, current_entry.run_id
6813        );
6814        return axum::response::Redirect::to(&canonical).into_response();
6815    }
6816
6817    let (Some(base_json), Some(curr_json)) = (
6818        baseline_entry.json_path.as_ref(),
6819        current_entry.json_path.as_ref(),
6820    ) else {
6821        let html = ErrorTemplate {
6822            message: "Full comparison requires JSON scan data, which was not saved for one or \
6823                      both of these runs. JSON is now always saved for new scans — re-run the \
6824                      affected projects to enable comparisons."
6825                .to_string(),
6826            last_report_url: Some("/compare-scans".to_string()),
6827            last_report_label: Some("Compare Scans".to_string()),
6828            run_id: None,
6829            error_code: None,
6830            csp_nonce: csp_nonce.clone(),
6831            version: env!("CARGO_PKG_VERSION"),
6832        }
6833        .render()
6834        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6835        return Html(html).into_response();
6836    };
6837
6838    let compare_url = format!(
6839        "/compare?a={}&b={}",
6840        baseline_entry.run_id, current_entry.run_id
6841    );
6842
6843    let baseline_run = match load_scan_for_compare(
6844        base_json,
6845        "baseline",
6846        &baseline_entry.run_id,
6847        state.server_mode,
6848        &compare_url,
6849        &csp_nonce,
6850    ) {
6851        Ok(r) => r,
6852        Err(resp) => return resp,
6853    };
6854    let current_run = match load_scan_for_compare(
6855        curr_json,
6856        "current",
6857        &current_entry.run_id,
6858        state.server_mode,
6859        &compare_url,
6860        &csp_nonce,
6861    ) {
6862        Ok(r) => r,
6863        Err(resp) => return resp,
6864    };
6865
6866    let active_submodule = query.sub.clone();
6867    let super_scope_active = query.scope.as_deref() == Some("super");
6868
6869    let submodule_options = baseline_run
6870        .submodule_summaries
6871        .iter()
6872        .chain(current_run.submodule_summaries.iter())
6873        .map(|s| s.name.clone())
6874        .collect::<std::collections::BTreeSet<_>>()
6875        .into_iter()
6876        .collect::<Vec<_>>();
6877    let has_any_submodule_data = !submodule_options.is_empty();
6878
6879    // Narrow per_file_records when a scope is active, then recompute totals.
6880    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6881        let mut b = baseline_run;
6882        let mut c = current_run;
6883        b.per_file_records
6884            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6885        c.per_file_records
6886            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6887        recompute_summary_from_records(&mut b);
6888        recompute_summary_from_records(&mut c);
6889        (b, c)
6890    } else if super_scope_active {
6891        let mut b = baseline_run;
6892        let mut c = current_run;
6893        b.per_file_records.retain(|f| f.submodule.is_none());
6894        c.per_file_records.retain(|f| f.submodule.is_none());
6895        recompute_summary_from_records(&mut b);
6896        recompute_summary_from_records(&mut c);
6897        (b, c)
6898    } else {
6899        (baseline_run, current_run)
6900    };
6901
6902    let comparison = compute_delta(&effective_baseline, &effective_current);
6903
6904    let file_rows: Vec<CompareFileDeltaRow> = comparison
6905        .file_deltas
6906        .iter()
6907        .map(|d| CompareFileDeltaRow {
6908            relative_path: d.relative_path.clone(),
6909            language: d.language.clone().unwrap_or_else(|| "—".into()),
6910            status: match d.status {
6911                FileChangeStatus::Added => "added".into(),
6912                FileChangeStatus::Removed => "removed".into(),
6913                FileChangeStatus::Modified => "modified".into(),
6914                FileChangeStatus::Unchanged => "unchanged".into(),
6915            },
6916            baseline_code: d.baseline_code,
6917            current_code: d.current_code,
6918            code_delta_str: fmt_delta(d.code_delta),
6919            code_delta_class: delta_class(d.code_delta).into(),
6920            comment_delta_str: fmt_delta(d.comment_delta),
6921            comment_delta_class: delta_class(d.comment_delta).into(),
6922            total_delta_str: fmt_delta(d.total_delta),
6923            total_delta_class: delta_class(d.total_delta).into(),
6924        })
6925        .collect();
6926
6927    let project_path = baseline_entry
6928        .input_roots
6929        .first()
6930        .map(|s| sanitize_path_str(s))
6931        .unwrap_or_default();
6932    let lines_added = sum_added_code_lines(&comparison);
6933    let lines_removed = sum_removed_code_lines(&comparison);
6934    let churn = compute_churn_stats(
6935        comparison.summary.baseline_code,
6936        comparison.summary.current_code,
6937        lines_added,
6938        lines_removed,
6939    );
6940    let s = &comparison.summary;
6941    let template = CompareTemplate {
6942        version: env!("CARGO_PKG_VERSION"),
6943        project_label: baseline_entry.project_label.clone(),
6944        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6945        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6946        baseline_run_id: baseline_entry.run_id.clone(),
6947        current_run_id: current_entry.run_id.clone(),
6948        baseline_run_id_short: baseline_entry
6949            .run_id
6950            .split('-')
6951            .next_back()
6952            .unwrap_or(&baseline_entry.run_id)
6953            .chars()
6954            .take(7)
6955            .collect(),
6956        current_run_id_short: current_entry
6957            .run_id
6958            .split('-')
6959            .next_back()
6960            .unwrap_or(&current_entry.run_id)
6961            .chars()
6962            .take(7)
6963            .collect(),
6964        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6965        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6966        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6967        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6968        project_path: project_path.clone(),
6969        baseline_code: s.baseline_code,
6970        current_code: s.current_code,
6971        code_lines_delta_str: fmt_delta(s.code_lines_delta),
6972        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6973        baseline_files: s.baseline_files,
6974        current_files: s.current_files,
6975        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6976        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6977        baseline_comments: s.baseline_comments,
6978        current_comments: s.current_comments,
6979        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6980        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6981        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6982        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6983        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6984        code_lines_added: lines_added,
6985        code_lines_removed: lines_removed,
6986        new_scope: churn.new_scope,
6987        churn_rate_str: churn.churn_rate_str,
6988        churn_rate_class: churn.churn_rate_class,
6989        scope_flag: churn.scope_flag,
6990        files_added: comparison.files_added,
6991        files_removed: comparison.files_removed,
6992        files_modified: comparison.files_modified,
6993        files_unchanged: comparison.files_unchanged,
6994        file_rows,
6995        baseline_git_author: baseline_entry.git_author.clone(),
6996        current_git_author: current_entry.git_author.clone(),
6997        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6998        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6999        baseline_git_tags: baseline_entry.git_tags.clone(),
7000        current_git_tags: current_entry.git_tags.clone(),
7001        baseline_git_commit_date: baseline_entry
7002            .git_commit_date
7003            .as_deref()
7004            .and_then(fmt_git_date),
7005        current_git_commit_date: current_entry
7006            .git_commit_date
7007            .as_deref()
7008            .and_then(fmt_git_date),
7009        project_name: project_path
7010            .rsplit(['/', '\\'])
7011            .find(|s| !s.is_empty())
7012            .unwrap_or(&project_path)
7013            .to_string(),
7014        submodule_options,
7015        has_any_submodule_data,
7016        active_submodule,
7017        super_scope_active,
7018        csp_nonce,
7019        coverage_delta_card: build_coverage_delta_card(s),
7020    };
7021
7022    Html(
7023        template
7024            .render()
7025            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7026    )
7027    .into_response()
7028}
7029
7030// ── Badge endpoint ────────────────────────────────────────────────────────────
7031// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
7032// pages, Jira descriptions, etc.
7033//
7034// GET /badge/<metric>?label=<override>&color=<hex>
7035// Metrics: code-lines  files  comment-lines  blank-lines
7036
7037fn format_number(n: u64) -> String {
7038    let s = n.to_string();
7039    let mut out = String::with_capacity(s.len() + s.len() / 3);
7040    let len = s.len();
7041    for (i, c) in s.chars().enumerate() {
7042        if i > 0 && (len - i).is_multiple_of(3) {
7043            out.push(',');
7044        }
7045        out.push(c);
7046    }
7047    out
7048}
7049
7050const fn badge_char_width(c: char) -> f64 {
7051    match c {
7052        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7053        'm' | 'w' => 9.0,
7054        ' ' => 4.0,
7055        _ => 6.5,
7056    }
7057}
7058
7059#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7060fn badge_text_px(text: &str) -> u32 {
7061    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7062}
7063
7064fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7065    let lw = badge_text_px(label) + 20;
7066    let rw = badge_text_px(value) + 20;
7067    let total = lw + rw;
7068    let lx = lw / 2;
7069    let rx = lw + rw / 2;
7070    let le = escape_html(label);
7071    let ve = escape_html(value);
7072    let ce = escape_html(color);
7073    format!(
7074        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7075  <rect width="{total}" height="20" fill="#555"/>
7076  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7077  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7078    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7079    <text x="{lx}" y="13">{le}</text>
7080    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7081    <text x="{rx}" y="13">{ve}</text>
7082  </g>
7083</svg>"##
7084    )
7085}
7086
7087#[derive(Deserialize)]
7088struct BadgeQuery {
7089    label: Option<String>,
7090    color: Option<String>,
7091}
7092
7093async fn badge_handler(
7094    State(state): State<AppState>,
7095    AxumPath(metric): AxumPath<String>,
7096    Query(query): Query<BadgeQuery>,
7097) -> Response {
7098    let entry = {
7099        let reg = state.registry.lock().await;
7100        reg.entries.first().cloned()
7101    };
7102
7103    let Some(entry) = entry else {
7104        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7105        return (
7106            [
7107                (header::CONTENT_TYPE, "image/svg+xml"),
7108                (header::CACHE_CONTROL, "no-cache, max-age=0"),
7109            ],
7110            svg,
7111        )
7112            .into_response();
7113    };
7114
7115    let (default_label, value, default_color) = match metric.as_str() {
7116        "code-lines" => (
7117            "code lines",
7118            format_number(entry.summary.code_lines),
7119            "#4a78ee",
7120        ),
7121        "files" => (
7122            "files analyzed",
7123            format_number(entry.summary.files_analyzed),
7124            "#4a9862",
7125        ),
7126        "comment-lines" => (
7127            "comment lines",
7128            format_number(entry.summary.comment_lines),
7129            "#b35428",
7130        ),
7131        "blank-lines" => (
7132            "blank lines",
7133            format_number(entry.summary.blank_lines),
7134            "#7a5db0",
7135        ),
7136        _ => return StatusCode::NOT_FOUND.into_response(),
7137    };
7138
7139    let label = query.label.as_deref().unwrap_or(default_label);
7140    let color = query.color.as_deref().unwrap_or(default_color);
7141    let svg = render_badge_svg(label, &value, color);
7142
7143    (
7144        [
7145            (header::CONTENT_TYPE, "image/svg+xml"),
7146            (header::CACHE_CONTROL, "no-cache, max-age=0"),
7147        ],
7148        svg,
7149    )
7150        .into_response()
7151}
7152
7153// ── Metrics API ───────────────────────────────────────────────────────────────
7154// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
7155// Confluence automation, Jira webhooks, etc.
7156//
7157// GET /api/metrics/latest
7158// GET /api/metrics/<run_id>
7159
7160#[derive(Serialize)]
7161struct ApiCoverageBlock {
7162    lines_found: u64,
7163    lines_hit: u64,
7164    line_pct: f64,
7165    functions_found: u64,
7166    functions_hit: u64,
7167    function_pct: f64,
7168    branches_found: u64,
7169    branches_hit: u64,
7170    branch_pct: f64,
7171}
7172
7173#[derive(Serialize)]
7174struct ApiMetricsResponse {
7175    run_id: String,
7176    timestamp: String,
7177    project: String,
7178    summary: ApiSummaryPayload,
7179    languages: Vec<ApiLanguageRow>,
7180    #[serde(skip_serializing_if = "Option::is_none")]
7181    coverage: Option<ApiCoverageBlock>,
7182}
7183
7184#[derive(Serialize)]
7185struct ApiSummaryPayload {
7186    files_analyzed: u64,
7187    files_skipped: u64,
7188    code_lines: u64,
7189    comment_lines: u64,
7190    blank_lines: u64,
7191    total_physical_lines: u64,
7192    functions: u64,
7193    classes: u64,
7194    variables: u64,
7195    imports: u64,
7196}
7197
7198#[derive(Serialize)]
7199struct ApiLanguageRow {
7200    name: String,
7201    files: u64,
7202    code_lines: u64,
7203    comment_lines: u64,
7204    blank_lines: u64,
7205    functions: u64,
7206    classes: u64,
7207    variables: u64,
7208    imports: u64,
7209}
7210
7211async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7212    let entry = {
7213        let reg = state.registry.lock().await;
7214        reg.entries.first().cloned()
7215    };
7216    entry.map_or_else(
7217        || error::not_found("no scans recorded yet"),
7218        |e| build_metrics_response(&e),
7219    )
7220}
7221
7222async fn api_metrics_run_handler(
7223    State(state): State<AppState>,
7224    AxumPath(run_id): AxumPath<String>,
7225) -> Response {
7226    let entry = {
7227        let reg = state.registry.lock().await;
7228        reg.find_by_run_id(&run_id).cloned()
7229    };
7230    entry.map_or_else(
7231        || error::not_found("run not found"),
7232        |e| build_metrics_response(&e),
7233    )
7234}
7235
7236fn build_metrics_response(entry: &RegistryEntry) -> Response {
7237    let languages: Vec<ApiLanguageRow> = entry
7238        .json_path
7239        .as_ref()
7240        .and_then(|p| read_json(p).ok())
7241        .map(|run| {
7242            run.totals_by_language
7243                .iter()
7244                .map(|l| ApiLanguageRow {
7245                    name: l.language.display_name().to_string(),
7246                    files: l.files,
7247                    code_lines: l.code_lines,
7248                    comment_lines: l.comment_lines,
7249                    blank_lines: l.blank_lines,
7250                    functions: l.functions,
7251                    classes: l.classes,
7252                    variables: l.variables,
7253                    imports: l.imports,
7254                })
7255                .collect()
7256        })
7257        .unwrap_or_default();
7258
7259    let s = &entry.summary;
7260    let coverage = if s.coverage_lines_found > 0 {
7261        let pct = |hit: u64, found: u64| -> f64 {
7262            if found == 0 {
7263                0.0
7264            } else {
7265                #[allow(clippy::cast_precision_loss)]
7266                let v = (hit as f64 / found as f64) * 100.0;
7267                (v * 10.0).round() / 10.0
7268            }
7269        };
7270        Some(ApiCoverageBlock {
7271            lines_found: s.coverage_lines_found,
7272            lines_hit: s.coverage_lines_hit,
7273            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7274            functions_found: s.coverage_functions_found,
7275            functions_hit: s.coverage_functions_hit,
7276            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7277            branches_found: s.coverage_branches_found,
7278            branches_hit: s.coverage_branches_hit,
7279            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7280        })
7281    } else {
7282        None
7283    };
7284    Json(ApiMetricsResponse {
7285        run_id: entry.run_id.clone(),
7286        timestamp: entry.timestamp_utc.to_rfc3339(),
7287        project: entry.project_label.clone(),
7288        summary: ApiSummaryPayload {
7289            files_analyzed: s.files_analyzed,
7290            files_skipped: s.files_skipped,
7291            code_lines: s.code_lines,
7292            comment_lines: s.comment_lines,
7293            blank_lines: s.blank_lines,
7294            total_physical_lines: s.total_physical_lines,
7295            functions: s.functions,
7296            classes: s.classes,
7297            variables: s.variables,
7298            imports: s.imports,
7299        },
7300        languages,
7301        coverage,
7302    })
7303    .into_response()
7304}
7305
7306// ── Project history API ───────────────────────────────────────────────────────
7307// Protected. Called by the wizard JS when the project path changes, so the UI
7308// can show a "scanned N times before" badge without a full page reload.
7309//
7310// GET /api/project-history?path=<project_root>
7311
7312#[derive(Deserialize)]
7313struct ProjectHistoryQuery {
7314    path: Option<String>,
7315}
7316
7317#[derive(Serialize)]
7318struct ProjectHistoryResponse {
7319    scan_count: usize,
7320    last_scan_id: Option<String>,
7321    last_scan_timestamp: Option<String>,
7322    last_scan_code_lines: Option<u64>,
7323    last_git_branch: Option<String>,
7324    last_git_commit: Option<String>,
7325}
7326
7327/// Return true if `entry` matches either an exact root path or an upload-staging
7328/// path with the same project name (needed because each upload gets a fresh UUID dir).
7329fn entry_matches_project(
7330    entry: &RegistryEntry,
7331    root_str: &str,
7332    upload_root: &str,
7333    upload_name_suffix: Option<&str>,
7334) -> bool {
7335    if entry.input_roots.iter().any(|r| r == root_str) {
7336        return true;
7337    }
7338    if let Some(suffix) = upload_name_suffix {
7339        return entry
7340            .input_roots
7341            .iter()
7342            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7343    }
7344    false
7345}
7346
7347async fn project_history_handler(
7348    State(state): State<AppState>,
7349    Query(query): Query<ProjectHistoryQuery>,
7350) -> Response {
7351    let path = query.path.unwrap_or_default();
7352    let resolved = resolve_input_path(&path);
7353    let root_str = resolved.to_string_lossy().replace('\\', "/");
7354
7355    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
7356    // The UUID is freshly generated for every upload, so an exact root_str match never finds
7357    // previous scans of the same project. Fall back to matching by project name within the
7358    // uploads staging directory so Scan History populates correctly across uploads.
7359    let upload_root = std::env::temp_dir()
7360        .join("oxide-sloc-uploads")
7361        .to_string_lossy()
7362        .replace('\\', "/");
7363    let upload_name_suffix: Option<String> =
7364        if state.server_mode && root_str.starts_with(&upload_root) {
7365            resolved
7366                .file_name()
7367                .and_then(|n| n.to_str())
7368                .map(|name| format!("/{name}"))
7369        } else {
7370            None
7371        };
7372    let suffix_ref = upload_name_suffix.as_deref();
7373
7374    let entries: Vec<_> = {
7375        let reg = state.registry.lock().await;
7376        reg.entries
7377            .iter()
7378            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7379            .cloned()
7380            .collect()
7381    };
7382    let scan_count = entries.len();
7383    let last = entries.first();
7384    let last_scan_id = last.map(|e| e.run_id.clone());
7385    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7386    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7387    let last_git_branch = last.and_then(|e| e.git_branch.clone());
7388    let last_git_commit = last.and_then(|e| e.git_commit.clone());
7389
7390    Json(ProjectHistoryResponse {
7391        scan_count,
7392        last_scan_id,
7393        last_scan_timestamp,
7394        last_scan_code_lines,
7395        last_git_branch,
7396        last_git_commit,
7397    })
7398    .into_response()
7399}
7400
7401// ── Metrics history API ───────────────────────────────────────────────────────
7402// Protected. Returns a JSON array of lightweight scan snapshots for plotting
7403// trend charts.
7404//
7405// GET /api/metrics/history?root=<path>&limit=<n>
7406
7407#[derive(Deserialize)]
7408struct MetricsHistoryQuery {
7409    root: Option<String>,
7410    limit: Option<usize>,
7411    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
7412    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
7413    submodule: Option<String>,
7414}
7415
7416#[derive(Serialize)]
7417struct MetricsSubmoduleLink {
7418    name: String,
7419    url: String,
7420}
7421
7422#[derive(Serialize)]
7423struct MetricsHistoryEntry {
7424    run_id: String,
7425    run_id_short: String,
7426    timestamp: String,
7427    commit: Option<String>,
7428    branch: Option<String>,
7429    tags: Vec<String>,
7430    nearest_tag: Option<String>,
7431    code_lines: u64,
7432    comment_lines: u64,
7433    blank_lines: u64,
7434    physical_lines: u64,
7435    files_analyzed: u64,
7436    files_skipped: u64,
7437    test_count: u64,
7438    project_label: String,
7439    html_url: Option<String>,
7440    has_pdf: bool,
7441    submodule_links: Vec<MetricsSubmoduleLink>,
7442    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
7443    #[serde(skip_serializing_if = "Option::is_none")]
7444    coverage_line_pct: Option<f64>,
7445}
7446
7447fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7448    let mut links: Vec<MetricsSubmoduleLink> = vec![];
7449    let sub_dir = e
7450        .html_path
7451        .as_ref()
7452        .and_then(|p| p.parent())
7453        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7454    let Some(dir) = sub_dir else { return links };
7455    let Ok(rd) = std::fs::read_dir(dir) else {
7456        return links;
7457    };
7458    for entry_res in rd.flatten() {
7459        let fname = entry_res.file_name();
7460        let fname_str = fname.to_string_lossy();
7461        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7462            let stem = &fname_str[..fname_str.len() - 5];
7463            let display = stem[4..].replace('-', " ");
7464            links.push(MetricsSubmoduleLink {
7465                name: display,
7466                url: format!("/runs/{stem}/{}", e.run_id),
7467            });
7468        }
7469    }
7470    links.sort_by(|a, b| a.name.cmp(&b.name));
7471    links
7472}
7473
7474fn apply_submodule_filter(
7475    base: MetricsHistoryEntry,
7476    filter: &str,
7477    e: &sloc_core::history::RegistryEntry,
7478) -> Option<MetricsHistoryEntry> {
7479    let json_path = e.json_path.as_ref()?;
7480    let json_str = std::fs::read_to_string(json_path).ok()?;
7481    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7482    let sub = run
7483        .submodule_summaries
7484        .iter()
7485        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7486    let safe = sanitize_project_label(&sub.name);
7487    let artifact_key = format!("sub_{safe}");
7488    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7489        || base.html_url.clone(),
7490        |run_dir| {
7491            let sub_path = run_dir.join(format!("{artifact_key}.html"));
7492            if sub_path.exists() {
7493                Some(format!("/runs/{artifact_key}/{}", e.run_id))
7494            } else {
7495                base.html_url.clone()
7496            }
7497        },
7498    );
7499
7500    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
7501    // basic SLOC totals, so test_count and coverage must be computed from file records.
7502    let sub_files: Vec<_> = run
7503        .per_file_records
7504        .iter()
7505        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7506        .collect();
7507    let test_count: u64 = sub_files
7508        .iter()
7509        .map(|r| r.raw_line_categories.test_count)
7510        .sum();
7511    #[allow(clippy::cast_precision_loss)]
7512    let coverage_line_pct: Option<f64> = {
7513        let found: u64 = sub_files
7514            .iter()
7515            .filter_map(|r| r.coverage.as_ref())
7516            .map(|c| u64::from(c.lines_found))
7517            .sum();
7518        let hit: u64 = sub_files
7519            .iter()
7520            .filter_map(|r| r.coverage.as_ref())
7521            .map(|c| u64::from(c.lines_hit))
7522            .sum();
7523        if found > 0 {
7524            let pct = (hit as f64 / found as f64) * 100.0;
7525            Some((pct * 10.0).round() / 10.0)
7526        } else {
7527            None
7528        }
7529    };
7530
7531    Some(MetricsHistoryEntry {
7532        code_lines: sub.code_lines,
7533        comment_lines: sub.comment_lines,
7534        blank_lines: sub.blank_lines,
7535        physical_lines: sub.total_physical_lines,
7536        files_analyzed: sub.files_analyzed,
7537        files_skipped: 0,
7538        test_count,
7539        html_url: sub_html_url,
7540        has_pdf: false,
7541        submodule_links: vec![],
7542        coverage_line_pct,
7543        ..base
7544    })
7545}
7546
7547#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
7548async fn api_metrics_history_handler(
7549    State(state): State<AppState>,
7550    Query(query): Query<MetricsHistoryQuery>,
7551) -> Response {
7552    let limit = query.limit.unwrap_or(50).min(500);
7553    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7554
7555    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7556        let reg = state.registry.lock().await;
7557        reg.entries
7558            .iter()
7559            .filter(|e| {
7560                query.root.as_ref().is_none_or(|root| {
7561                    let resolved = resolve_input_path(root);
7562                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7563                    e.input_roots.iter().any(|r| r == &root_str)
7564                })
7565            })
7566            .take(limit)
7567            .cloned()
7568            .collect()
7569    };
7570
7571    let entries: Vec<MetricsHistoryEntry> = candidate_entries
7572        .into_iter()
7573        .filter_map(|e| {
7574            let tags = e
7575                .git_tags
7576                .as_deref()
7577                .map(|s| {
7578                    s.split(',')
7579                        .map(|t| t.trim().to_string())
7580                        .filter(|t| !t.is_empty())
7581                        .collect()
7582                })
7583                .unwrap_or_default();
7584            let html_url = e
7585                .html_path
7586                .as_ref()
7587                .filter(|p| p.exists())
7588                .map(|_| format!("/runs/html/{}", e.run_id));
7589            let nearest_tag = e.git_nearest_tag.clone();
7590            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7591            let run_id_short: String = e
7592                .run_id
7593                .split('-')
7594                .next_back()
7595                .unwrap_or(&e.run_id)
7596                .chars()
7597                .take(7)
7598                .collect();
7599            let submodule_links = build_entry_submodule_links(&e);
7600            #[allow(clippy::cast_precision_loss)]
7601            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7602                let pct = (e.summary.coverage_lines_hit as f64
7603                    / e.summary.coverage_lines_found as f64)
7604                    * 100.0;
7605                Some((pct * 10.0).round() / 10.0)
7606            } else {
7607                None
7608            };
7609            let base = MetricsHistoryEntry {
7610                run_id: e.run_id.clone(),
7611                run_id_short,
7612                timestamp: e.timestamp_utc.to_rfc3339(),
7613                commit: e.git_commit.clone(),
7614                branch: e.git_branch.clone(),
7615                tags,
7616                nearest_tag,
7617                code_lines: e.summary.code_lines,
7618                comment_lines: e.summary.comment_lines,
7619                blank_lines: e.summary.blank_lines,
7620                physical_lines: e.summary.total_physical_lines,
7621                files_analyzed: e.summary.files_analyzed,
7622                files_skipped: e.summary.files_skipped,
7623                test_count: e.summary.test_count,
7624                project_label: e.project_label.clone(),
7625                html_url,
7626                has_pdf,
7627                submodule_links,
7628                coverage_line_pct,
7629            };
7630            if let Some(ref filter) = submodule_filter {
7631                apply_submodule_filter(base, filter, &e)
7632            } else {
7633                Some(base)
7634            }
7635        })
7636        .collect();
7637
7638    Json(entries).into_response()
7639}
7640
7641// GET /api/metrics/submodules?root=<path>
7642// Returns the union of distinct submodule names found across all saved scan JSON artifacts
7643// for the given project root (or all roots if omitted).
7644#[derive(Deserialize)]
7645struct MetricsSubmodulesQuery {
7646    root: Option<String>,
7647}
7648
7649#[derive(Serialize)]
7650struct SubmoduleEntry {
7651    name: String,
7652    relative_path: String,
7653}
7654
7655async fn api_metrics_submodules_handler(
7656    State(state): State<AppState>,
7657    Query(query): Query<MetricsSubmodulesQuery>,
7658) -> Response {
7659    let json_paths: Vec<std::path::PathBuf> = {
7660        let reg = state.registry.lock().await;
7661        reg.entries
7662            .iter()
7663            .filter(|e| {
7664                query.root.as_ref().is_none_or(|root| {
7665                    let resolved = resolve_input_path(root);
7666                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7667                    e.input_roots.iter().any(|r| r == &root_str)
7668                })
7669            })
7670            .filter_map(|e| e.json_path.clone())
7671            .collect()
7672    };
7673
7674    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7675    let mut result: Vec<SubmoduleEntry> = Vec::new();
7676
7677    for path in &json_paths {
7678        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7679            continue;
7680        };
7681        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
7682            continue;
7683        };
7684        for sub in &run.submodule_summaries {
7685            if seen.insert(sub.name.clone()) {
7686                result.push(SubmoduleEntry {
7687                    name: sub.name.clone(),
7688                    relative_path: sub.relative_path.clone(),
7689                });
7690            }
7691        }
7692    }
7693
7694    result.sort_by(|a, b| a.name.cmp(&b.name));
7695    Json(result).into_response()
7696}
7697
7698// ── CI ingest endpoint ────────────────────────────────────────────────────────
7699// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
7700// server stores and displays results without cloning or scanning anything itself.
7701//
7702// POST /api/ingest?label=<optional_display_name>
7703// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
7704// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
7705
7706#[derive(Deserialize)]
7707struct IngestQuery {
7708    label: Option<String>,
7709}
7710
7711#[derive(Serialize)]
7712struct IngestResponse {
7713    run_id: String,
7714    view_url: String,
7715}
7716
7717async fn api_ingest_handler(
7718    State(state): State<AppState>,
7719    Query(q): Query<IngestQuery>,
7720    Json(run): Json<sloc_core::AnalysisRun>,
7721) -> Response {
7722    let label = q.label.unwrap_or_else(|| {
7723        run.input_roots
7724            .first()
7725            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
7726    });
7727
7728    let label_for_task = label.clone();
7729    let result = tokio::task::spawn_blocking(move || {
7730        let html = render_html(&run)?;
7731        let run_id = run.tool.run_id.clone();
7732        let run_id_safe = run_id.len() <= 128
7733            && !run_id.is_empty()
7734            && run_id
7735                .chars()
7736                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
7737        if !run_id_safe {
7738            anyhow::bail!(
7739                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
7740            );
7741        }
7742        let project_label = sanitize_project_label(&label_for_task);
7743        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
7744        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
7745            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
7746            _ => project_label,
7747        };
7748        let (artifacts, _pending_pdf) = persist_run_artifacts(
7749            &run,
7750            &html,
7751            &output_dir,
7752            &label_for_task,
7753            &file_stem,
7754            RunResultContext::default(),
7755        )?;
7756        Ok::<_, anyhow::Error>((run_id, artifacts, run))
7757    })
7758    .await;
7759
7760    match result {
7761        Ok(Ok((run_id, artifacts, run))) => {
7762            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
7763            (
7764                StatusCode::CREATED,
7765                Json(IngestResponse {
7766                    view_url: format!("/view-reports?run_id={run_id}"),
7767                    run_id,
7768                }),
7769            )
7770                .into_response()
7771        }
7772        Ok(Err(e)) => error::internal(&format!("{e:#}")),
7773        Err(e) => error::internal(&format!("{e}")),
7774    }
7775}
7776
7777// ── Trend report page ─────────────────────────────────────────────────────────
7778// Protected. Interactive time-series chart page that loads scan history via
7779// /api/metrics/history and renders a vanilla-SVG line chart.
7780//
7781// GET /trend-reports
7782
7783#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
7784async fn trend_report_handler(
7785    State(state): State<AppState>,
7786    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7787) -> Response {
7788    auto_scan_watched_dirs(&state).await;
7789
7790    let watched_dirs_list: Vec<String> = {
7791        let wd = state.watched_dirs.lock().await;
7792        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7793    };
7794
7795    // Collect distinct project roots for the root selector dropdown.
7796    let roots: Vec<String> = {
7797        let reg = state.registry.lock().await;
7798        let mut seen = std::collections::BTreeSet::new();
7799        reg.entries
7800            .iter()
7801            .flat_map(|e| e.input_roots.iter().cloned())
7802            .filter(|r| seen.insert(r.clone()))
7803            .collect()
7804    };
7805
7806    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
7807    let nonce = &csp_nonce;
7808    let version = env!("CARGO_PKG_VERSION");
7809
7810    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
7811    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
7812    // of interactive controls — folder watching is managed by the host administrator.
7813    let watched_dirs_html: String = if state.server_mode {
7814        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()
7815    } else {
7816        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7817            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7818                .to_string()
7819        } else {
7820            watched_dirs_list
7821                .iter()
7822                .fold(String::new(), |mut s, d| {
7823                    use std::fmt::Write as _;
7824                    let escaped =
7825                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
7826                    write!(
7827                        s,
7828                        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>"#
7829                    ).expect("write to String is infallible");
7830                    s
7831                })
7832        };
7833        format!(
7834            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>"#
7835        )
7836    };
7837
7838    let html = format!(
7839        r##"<!doctype html>
7840<html lang="en">
7841<head>
7842  <meta charset="utf-8" />
7843  <meta name="viewport" content="width=device-width, initial-scale=1" />
7844  <title>OxideSLOC | Trend Reports</title>
7845  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7846  <style nonce="{nonce}">
7847    :root {{
7848      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7849      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7850      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7851      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7852      --info-bg:#eef3ff; --info-text:#4467d8;
7853    }}
7854    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7855    *{{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;}}
7856    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7857    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7858    .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;}}
7859    @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));}}}}
7860    .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);}}
7861    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7862    .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));}}
7863    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7864    .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;}}
7865    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7866    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7867    @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; }} }}
7868    .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;}}
7869    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7870    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7871    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7872    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7873    .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;}}
7874    .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;}}
7875    .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;}}
7876    .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;}}
7877    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7878    .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);}}
7879    .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;}}
7880    .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;}}
7881    .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;}}
7882    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7883    .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;}}
7884    .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);}}
7885    .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;}}
7886    .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;}}
7887    .tz-select:focus{{border-color:var(--oxide);}}
7888    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7889    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
7890    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7891    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7892    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7893    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7894    .trend-title-block{{flex:1;min-width:0;}}
7895    .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;}}
7896    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7897    .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;}}
7898    .chart-select:focus{{border-color:var(--accent);}}
7899    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7900    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7901    .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;}}
7902    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7903    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7904    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7905    .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);}}
7906    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7907    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7908    .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;}}
7909    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7910    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7911    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7912    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7913    .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;}}
7914    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7915    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7916    .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);}}
7917    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7918    .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;}}
7919    .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;}}
7920    .data-table tr:last-child td{{border-bottom:none;}}
7921    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7922    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7923    .table-wrap{{width:100%;overflow-x:auto;}}
7924    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7925    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7926    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7927    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7928    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7929    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7930    .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;}}
7931    .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;}}
7932    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7933    .pagination-info{{font-size:13px;color:var(--muted);}}
7934    .pagination-btns{{display:flex;gap:6px;}}
7935    .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;}}
7936    .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;}}
7937    #scan-history-table col:nth-child(1){{width:155px;}}
7938    #scan-history-table col:nth-child(2){{width:240px;}}
7939    #scan-history-table col:nth-child(3){{width:82px;}}
7940    #scan-history-table col:nth-child(4){{width:82px;}}
7941    #scan-history-table col:nth-child(5){{width:90px;}}
7942    #scan-history-table col:nth-child(6){{width:90px;}}
7943    #scan-history-table col:nth-child(7){{width:88px;}}
7944    #scan-history-table col:nth-child(8){{width:150px;}}
7945    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7946    .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;}}
7947    .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;}}
7948    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7949    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7950    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7951    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7952    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7953    .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;}}
7954    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7955    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7956    .watched-chip-rm:hover{{color:var(--oxide);}}
7957    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7958    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7959    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7960    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7961    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7962    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7963    a.run-link:hover{{text-decoration:underline;}}
7964    .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);}}
7965    .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);}}
7966    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7967    .metric-num{{font-weight:700;color:var(--text);}}
7968    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7969    .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;}}
7970    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7971    .btn.primary:hover{{opacity:.9;}}
7972    .rpt-btn{{min-width:58px;justify-content:center;}}
7973    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7974    .report-cell{{overflow:visible!important;white-space:normal!important;}}
7975    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7976    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7977    .submod-details summary::-webkit-details-marker{{display:none;}}
7978    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7979    .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;}}
7980    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7981    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7982    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7983    .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;}}
7984    .export-btn:hover{{background:var(--line);}}
7985    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7986    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7987    .site-footer a{{color:var(--muted);}}
7988    .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;}}
7989    .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;}}
7990    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7991  </style>
7992</head>
7993<body>
7994  <div class="background-watermarks" aria-hidden="true">
7995    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7996    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7997    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7998    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7999    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8000    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8001  </div>
8002  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8003  <div class="top-nav">
8004    <div class="top-nav-inner">
8005      <a class="brand" href="/">
8006        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8007        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
8008      </a>
8009      <div class="nav-right">
8010        <a class="nav-pill" href="/">Home</a>
8011        <div class="nav-dropdown">
8012          <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>
8013          <div class="nav-dropdown-menu">
8014            <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>
8015          </div>
8016        </div>
8017        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8018        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
8019        <div class="nav-dropdown">
8020          <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>
8021          <div class="nav-dropdown-menu">
8022            <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>
8023          </div>
8024        </div>
8025        <div class="server-status-wrap" id="server-status-wrap">
8026          <div class="nav-pill server-online-pill" id="server-status-pill">
8027            <span class="status-dot" id="status-dot"></span>
8028            <span id="server-status-label">Server</span>
8029            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8030          </div>
8031          <div class="server-status-tip">
8032            OxideSLOC is running — accessible on your network.
8033            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8034          </div>
8035        </div>
8036        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8037          <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>
8038        </button>
8039        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8040          <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>
8041          <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>
8042        </button>
8043      </div>
8044    </div>
8045  </div>
8046
8047  <div class="page">
8048    {watched_dirs_html}
8049    <div class="summary-strip" id="trend-stats"></div>
8050    <div class="panel">
8051      <div class="trend-header">
8052        <div class="trend-title-block">
8053          <h1>Trend Reports</h1>
8054          <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>
8055          <span class="chart-hint-inline">
8056            <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>
8057            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
8058          </span>
8059        </div>
8060        <div class="chart-actions">
8061          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
8062            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
8063            Retention Policy
8064          </button>
8065          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
8066            <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>
8067            Clean up old runs
8068          </button>
8069          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
8070            <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>
8071            Export Excel
8072          </button>
8073          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
8074            <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>
8075            Export PNG
8076          </button>
8077        </div>
8078      </div>
8079
8080      <div class="controls-centered">
8081        <label>Project Root:
8082          <select class="chart-select" id="root-sel">
8083            <option value="">All projects</option>
8084          </select>
8085        </label>
8086        <label>Y Metric:
8087          <select class="chart-select" id="y-sel">
8088            <option value="code_lines">Code Lines</option>
8089            <option value="comment_lines">Comment Lines</option>
8090            <option value="blank_lines">Blank Lines</option>
8091            <option value="physical_lines">Physical Lines</option>
8092            <option value="files_analyzed">Files Analyzed</option>
8093          </select>
8094        </label>
8095        <label>X Axis:
8096          <select class="chart-select" id="x-sel">
8097            <option value="time">By Time</option>
8098            <option value="commit">By Commit</option>
8099            <option value="release">By Release</option>
8100            <option value="tag">Tagged Commits</option>
8101          </select>
8102        </label>
8103        <label id="submodule-label" style="display:none;">Submodule:
8104          <select class="chart-select" id="sub-sel">
8105            <option value="">All (project total)</option>
8106          </select>
8107        </label>
8108        <label>Chart Size:
8109          <select class="chart-select" id="scale-sel">
8110            <option value="0.75">Compact</option>
8111            <option value="1.2" selected>Normal</option>
8112            <option value="1.38">Large</option>
8113          </select>
8114        </label>
8115      </div>
8116
8117      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
8118      <div id="data-table-wrap" style="overflow-x:auto;"></div>
8119    </div>
8120  </div>
8121
8122  <script nonce="{nonce}">
8123    (function() {{
8124      // Theme persistence
8125      var b = document.body;
8126      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8127      var tgl = document.getElementById('theme-toggle');
8128      if (tgl) tgl.addEventListener('click', function() {{
8129        var d = b.classList.toggle('dark-theme');
8130        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8131      }});
8132
8133      // Watermark randomizer
8134      (function() {{
8135        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8136        if (!wms.length) return;
8137        var placed = [];
8138        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;}}
8139        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];}}
8140        var half=Math.floor(wms.length/2);
8141        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;}});
8142      }})();
8143
8144      // Code particles
8145      (function() {{
8146        var container = document.getElementById('code-particles');
8147        if (!container) return;
8148        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'];
8149        for (var i = 0; i < 38; i++) {{
8150          (function(idx) {{
8151            var el = document.createElement('span');
8152            el.className = 'code-particle';
8153            el.textContent = snippets[idx % snippets.length];
8154            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8155            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8156            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8157            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';
8158            container.appendChild(el);
8159          }})(i);
8160        }}
8161      }})();
8162
8163      // Watched folder picker
8164      (function() {{
8165        var btn = document.getElementById('add-watched-btn');
8166        if (!btn) return;
8167        btn.addEventListener('click', function() {{
8168          fetch('/pick-directory?kind=reports')
8169            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8170            .then(function(data) {{
8171              if (!data.cancelled && data.selected_path) {{
8172                var form = document.createElement('form');
8173                form.method = 'POST';
8174                form.action = '/watched-dirs/add';
8175                var ri = document.createElement('input');
8176                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8177                var fi = document.createElement('input');
8178                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8179                form.appendChild(ri); form.appendChild(fi);
8180                document.body.appendChild(form);
8181                form.submit();
8182              }}
8183            }})
8184            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8185        }});
8186      }})();
8187
8188      // Settings / color-scheme modal
8189      (function() {{
8190        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'}}];
8191        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);}});}}
8192        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8193        var btn=document.getElementById('settings-btn');if(!btn)return;
8194        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8195        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>';
8196        document.body.appendChild(m);
8197        var g=document.getElementById('scheme-grid');
8198        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);}});
8199        var cl=document.getElementById('settings-close');
8200        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);
8201        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');}});
8202        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8203        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8204      }})();
8205    }})();
8206
8207    var ROOTS = {roots_json};
8208    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8209    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
8210    var allData = [];
8211
8212    // Populate root selector
8213    var rootSel = document.getElementById('root-sel');
8214    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
8215
8216    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();}}
8217    function fmtFull(n){{return Number(n).toLocaleString();}}
8218    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
8219
8220    // Tooltip
8221    var tt = document.createElement('div');
8222    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);';
8223    document.body.appendChild(tt);
8224    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
8225    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';}}
8226    function hideTT(){{tt.style.display='none';}}
8227    window.addEventListener('blur',function(){{hideTT();}});
8228    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
8229
8230    function statExact(compact, full){{
8231      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
8232    }}
8233    function statVal(n){{
8234      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
8235    }}
8236
8237    function updateStats(data){{
8238      var statsEl=document.getElementById('trend-stats');
8239      if(!statsEl)return;
8240      if(!data||!data.length){{statsEl.innerHTML='';return;}}
8241      var yKey=document.getElementById('y-sel').value;
8242      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8243      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8244      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
8245      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
8246      var absDelta=Math.abs(delta);
8247      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
8248      var deltaExact=statExact(deltaCompact,deltaFull);
8249      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
8250      statsEl.innerHTML=
8251        '<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>'+
8252        '<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>'+
8253        '<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>'+
8254        '<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>';
8255    }}
8256
8257    var subSel = document.getElementById('sub-sel');
8258    var subLabel = document.getElementById('submodule-label');
8259
8260    function populateSubmodules(root){{
8261      if(!subSel||!subLabel)return;
8262      while(subSel.options.length>1)subSel.remove(1);
8263      subSel.value='';
8264      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
8265      fetch(url)
8266        .then(function(r){{return r.json();}})
8267        .then(function(subs){{
8268          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
8269          subs.forEach(function(s){{
8270            var o=document.createElement('option');
8271            o.value=s.name;
8272            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
8273            subSel.appendChild(o);
8274          }});
8275          subLabel.style.display='';
8276        }})
8277        .catch(function(){{subLabel.style.display='none';}});
8278    }}
8279
8280    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
8281
8282    function loadAndRender(){{
8283      var root = rootSel.value;
8284      var sub = subSel ? subSel.value : '';
8285      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
8286      document.getElementById('data-table-wrap').innerHTML='';
8287      var url = '/api/metrics/history?limit=100'
8288        + (root ? '&root='+encodeURIComponent(root) : '')
8289        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
8290      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
8291        allData = data;
8292        render(data);
8293        updateStats(data);
8294      }}).catch(function(){{
8295        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>';
8296      }});
8297    }}
8298
8299    function render(data){{
8300      var yKey = document.getElementById('y-sel').value;
8301      var xMode = document.getElementById('x-sel').value;
8302
8303      // Filter for tag/release mode
8304      var pts = data;
8305      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
8306
8307      // Sort oldest-first for the line chart
8308      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8309
8310      var wrap = document.getElementById('chart-wrap');
8311      if(!pts.length){{
8312        var emptyMsg = (xMode === 'tag')
8313          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
8314          : 'No scan data found for the selected filters.';
8315        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
8316        renderTable([]);
8317        return;
8318      }}
8319
8320      var scaleEl=document.getElementById('scale-sel');
8321      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
8322      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;
8323      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
8324
8325      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8326
8327      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">';
8328      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>';
8329
8330      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
8331
8332      // Grid + Y axis ticks
8333      for(var ti=0;ti<=5;ti++){{
8334        var gy=PT+CH-Math.round(ti/5*CH);
8335        var gv=Math.round(ti/5*maxY);
8336        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
8337        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
8338      }}
8339
8340      // X axis labels (every N-th point to avoid crowding)
8341      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
8342      pts.forEach(function(d,i){{
8343        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8344        if(i%labelEvery===0||i===pts.length-1){{
8345          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)));
8346          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>';
8347        }}
8348      }});
8349
8350      // Axis label
8351      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
8352      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>';
8353      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>';
8354
8355      // Area fill + line path
8356      var pathD='';
8357      pts.forEach(function(d,i){{
8358        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8359        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8360        pathD+=(i===0?'M':'L')+x+','+y;
8361      }});
8362      if(pts.length>1){{
8363        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
8364        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
8365      }}
8366      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
8367
8368      // Data points (clickable) + permanent value labels
8369      var showLabels = pts.length <= 40;
8370      var labelEveryN = pts.length > 20 ? 2 : 1;
8371      pts.forEach(function(d,i){{
8372        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8373        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8374        var hasTags=d.tags&&d.tags.length>0;
8375        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
8376        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
8377        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+'"/>';
8378        if(showLabels && i%labelEveryN===0){{
8379          var lx=x, ly=y-r-5;
8380          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>';
8381        }}
8382      }});
8383
8384      svg+='</svg>';
8385      wrap.innerHTML=svg;
8386
8387      // Attach point tooltips
8388      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
8389        c.addEventListener('mouseover',function(e){{
8390          var d=pts[parseInt(this.dataset.idx)];
8391          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(''):'';
8392          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>':'';
8393          showTT(e,
8394            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
8395            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
8396            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
8397            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
8398          );
8399          this.setAttribute('r','8');
8400        }});
8401        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
8402        c.addEventListener('mousemove',moveTT);
8403        c.addEventListener('click',function(){{
8404          var d=pts[parseInt(this.dataset.idx)];
8405          if(d.html_url) window.open(d.html_url,'_blank');
8406        }});
8407      }});
8408
8409      renderTable(pts, yKey);
8410    }}
8411
8412    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
8413    var shProjFilter='', shBranchFilter='';
8414
8415    function fmtPST(isoStr){{
8416      if(!isoStr)return'';
8417      var d=new Date(isoStr);
8418      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
8419      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);}}
8420      function p(n){{return n<10?'0'+n:String(n);}}
8421      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++;}}}}
8422      var yr=d.getUTCFullYear();
8423      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
8424      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
8425      var isDST=d>=dstStart&&d<dstEnd;
8426      var off=isDST?-7*3600*1000:-8*3600*1000;
8427      var lbl=isDST?'PDT':'PST';
8428      var loc=new Date(d.getTime()+off);
8429      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
8430    }}
8431
8432    function getShRows(){{
8433      var proj=shProjFilter.toLowerCase().trim();
8434      var branch=shBranchFilter;
8435      return shData.filter(function(d){{
8436        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
8437        if(branch&&(d.branch||'')!==branch)return false;
8438        return true;
8439      }});
8440    }}
8441
8442    function renderShPage(){{
8443      var filtered=getShRows();
8444      if(shSortCol){{
8445        filtered.sort(function(a,b){{
8446          var va,vb;
8447          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
8448          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
8449          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
8450          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
8451          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
8452          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
8453        }});
8454      }}
8455      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
8456      shPage=Math.min(shPage,totalPages);
8457      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
8458      var visible=filtered.slice(start,end);
8459      var tbody=document.getElementById('sh-tbody');
8460      if(!tbody)return;
8461      tbody.innerHTML=visible.map(function(d){{
8462        var tsHtml=esc(fmtPST(d.timestamp));
8463        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>';
8464        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>';
8465        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
8466        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
8467        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
8468        var reportCell='';
8469        if(d.html_url){{
8470          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
8471          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>';}}
8472          reportCell+='</div>';
8473        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
8474        if(d.submodule_links&&d.submodule_links.length){{
8475          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
8476          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
8477          reportCell+='</div></details>';
8478        }}
8479        return '<tr>'
8480          +'<td>'+tsHtml+'</td>'
8481          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
8482          +'<td>'+runIdHtml+'</td>'
8483          +'<td>'+commitHtml+'</td>'
8484          +'<td>'+branchHtml+'</td>'
8485          +'<td>'+tags+'</td>'
8486          +'<td class="num">'+metricHtml+'</td>'
8487          +'<td class="report-cell">'+reportCell+'</td>'
8488          +'</tr>';
8489      }}).join('');
8490      var pgRange=document.getElementById('sh-pg-range');
8491      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
8492      var pgInfo=document.getElementById('sh-pg-info');
8493      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
8494      var pgBtns=document.getElementById('sh-pg-btns');
8495      if(pgBtns){{
8496        pgBtns.innerHTML='';
8497        function mkPgBtn(lbl,pg,active,disabled){{
8498          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
8499          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
8500          return b;
8501        }}
8502        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
8503        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
8504        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
8505        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
8506      }}
8507    }}
8508
8509    function wireTableBehavior(){{
8510      var pf=document.getElementById('sh-proj-filter');
8511      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
8512      var bf=document.getElementById('sh-branch-filter');
8513      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
8514      var rb=document.getElementById('sh-reset-btn');
8515      if(rb)rb.addEventListener('click',function(){{
8516        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
8517        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
8518        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
8519        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');}});
8520        renderShPage();
8521      }});
8522      var pps=document.getElementById('sh-per-page');
8523      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
8524      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
8525      ths.forEach(function(th){{
8526        th.addEventListener('click',function(e){{
8527          if(e.target.classList.contains('col-resize-handle'))return;
8528          var col=th.dataset.col;
8529          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
8530          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
8531          th.classList.add('sort-'+shSortOrder);
8532          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
8533          shPage=1;renderShPage();
8534        }});
8535      }});
8536      var table=document.getElementById('scan-history-table');
8537      if(!table)return;
8538      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
8539      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
8540      allThs.forEach(function(th,i){{
8541        var handle=th.querySelector('.col-resize-handle');
8542        if(!handle||!cols[i])return;
8543        var startX,startW;
8544        handle.addEventListener('mousedown',function(e){{
8545          e.stopPropagation();e.preventDefault();
8546          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
8547          handle.classList.add('dragging');
8548          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
8549          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
8550          document.addEventListener('mousemove',onMove);
8551          document.addEventListener('mouseup',onUp);
8552        }});
8553      }});
8554    }}
8555
8556    function renderTable(pts, yKey){{
8557      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
8558      var wrap=document.getElementById('data-table-wrap');
8559      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
8560      var yLabel=Y_LABELS[yKey]||yKey||'';
8561      shData=pts.slice().reverse();
8562      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
8563      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
8564      var branches={{}};
8565      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
8566      var branchOpts='<option value="">All branches</option>';
8567      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
8568      wrap.innerHTML=
8569        '<div class="chart-section-header">SCAN HISTORY</div>'+
8570        '<div class="filter-row">'+
8571          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
8572          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
8573          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
8574        '</div>'+
8575        '<div class="table-wrap">'+
8576        '<table id="scan-history-table" class="data-table">'+
8577        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
8578        '<thead><tr id="sh-thead">'+
8579        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8580        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8581        '<th>Run ID<div class="col-resize-handle"></div></th>'+
8582        '<th>Commit<div class="col-resize-handle"></div></th>'+
8583        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8584        '<th>Tags<div class="col-resize-handle"></div></th>'+
8585        '<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>'+
8586        '<th>Report<div class="col-resize-handle"></div></th>'+
8587        '</tr></thead>'+
8588        '<tbody id="sh-tbody"></tbody>'+
8589        '</table>'+
8590        '</div>'+
8591        '<div class="pagination">'+
8592          '<span class="pagination-info" id="sh-pg-info"></span>'+
8593          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
8594          '<div style="display:flex;align-items:center;gap:8px;">'+
8595            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
8596            '<select class="filter-select" id="sh-per-page">'+
8597              '<option value="10">10 per page</option>'+
8598              '<option value="25" selected>25 per page</option>'+
8599              '<option value="50">50 per page</option>'+
8600              '<option value="100">100 per page</option>'+
8601            '</select>'+
8602            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
8603          '</div>'+
8604        '</div>';
8605      wireTableBehavior();
8606      renderShPage();
8607    }}
8608
8609    function exportXLSX(){{
8610      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
8611      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
8612      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
8613      var s1R=sorted.map(function(d){{
8614        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||''];
8615      }});
8616      var pm={{}};
8617      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
8618      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'];
8619      var s2R=Object.keys(pm).map(function(p){{
8620        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8621        var lat=sc[sc.length-1],fst=sc[0];
8622        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
8623        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);
8624        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];
8625      }});
8626      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
8627      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
8628      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
8629      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
8630    }}
8631
8632    function buildXLSX(sheets,chartRows,chartRows2){{
8633      function s2b(s){{return new TextEncoder().encode(s);}}
8634      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
8635      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;}}
8636      function crc32(d){{
8637        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;}}}}
8638        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
8639      }}
8640      function buildSheet(hdr,rows,drawRid,withCtrl){{
8641        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
8642        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
8643        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
8644        x+='<row r="1">';
8645        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
8646        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>';}}
8647        x+='</row>';
8648        rows.forEach(function(row,ri){{
8649          var rn=ri+2;
8650          x+='<row r="'+rn+'">';
8651          row.forEach(function(cell,ci){{
8652            var addr=col2l(ci+1)+rn;
8653            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
8654            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
8655          }});
8656          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>';}}
8657          x+='</row>';
8658        }});
8659        x+='</sheetData>';
8660        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>';}}
8661        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
8662        return x+'</worksheet>';
8663      }}
8664      function buildChartXML(rows){{
8665        var sn="'Scan History'";
8666        var nr=rows.length,er=nr+1;
8667        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'}}];
8668        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8669        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">';
8670        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8671        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8672        sd.forEach(function(s,i){{
8673          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8674          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>';
8675          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8676          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>';
8677          var dlp=(i===2)?'b':'t';
8678          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>';
8679          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8680          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8681          x+='</c:strCache></c:strRef></c:cat>';
8682          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+'"/>';
8683          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8684          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8685        }});
8686        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
8687        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>';
8688        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>';
8689        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8690        return x;
8691      }}
8692      function buildChartXML2(rows){{
8693        var sn="'By Project'";
8694        var nr=rows.length,er=nr+1;
8695        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'}}];
8696        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8697        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">';
8698        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8699        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8700        sd.forEach(function(s,i){{
8701          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8702          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>';
8703          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8704          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>';
8705          var dlp=(i===2)?'b':'t';
8706          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>';
8707          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8708          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8709          x+='</c:strCache></c:strRef></c:cat>';
8710          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+'"/>';
8711          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8712          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8713        }});
8714        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
8715        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>';
8716        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>';
8717        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8718        return x;
8719      }}
8720      function buildChartXML3(rows){{
8721        var sn="'Scan History'";
8722        var nr=rows.length,er=nr+1;
8723        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8724        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">';
8725        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
8726        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8727        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
8728        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>';
8729        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
8730        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>';
8731        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>';
8732        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8733        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8734        x+='</c:strCache></c:strRef></c:cat>';
8735        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+'"/>';
8736        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
8737        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8738        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
8739        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>';
8740        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>';
8741        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>';
8742        return x;
8743      }}
8744      var hasChart=!!(chartRows&&chartRows.length);
8745      var nr=hasChart?chartRows.length:0;
8746      var hasChart2=!!(chartRows2&&chartRows2.length);
8747      var nr2=hasChart2?chartRows2.length:0;
8748      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>';
8749      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"/>';
8750      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
8751      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"/>';}}
8752      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"/>';}}
8753      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
8754      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>';
8755      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
8756      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"/>';}});
8757      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
8758      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>';
8759      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
8760      wbx+='</sheets></workbook>';
8761      var files=[
8762        {{name:'[Content_Types].xml',data:s2b(ct)}},
8763        {{name:'_rels/.rels',data:s2b(dotrels)}},
8764        {{name:'xl/workbook.xml',data:s2b(wbx)}},
8765        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
8766        {{name:'xl/styles.xml',data:s2b(styl)}}
8767      ];
8768      // Chart embedded directly in Scan History (sheet1); By Project is plain
8769      sheets.forEach(function(s,i){{
8770        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)))}});
8771      }});
8772      if(hasChart){{
8773        var fromRow=nr+4,toRow=nr+24;
8774        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>')}});
8775        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8776        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">';
8777        drx+='<xdr:twoCellAnchor editAs="twoCell">';
8778        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>';
8779        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>';
8780        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8781        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8782        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8783        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8784        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
8785        var focRow=toRow+2,focRowEnd=toRow+22;
8786        drx+='<xdr:twoCellAnchor editAs="twoCell">';
8787        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>';
8788        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>';
8789        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8790        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8791        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8792        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
8793        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
8794        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
8795        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>')}});
8796        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
8797        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
8798      }}
8799      if(hasChart2){{
8800        var fromRow2=nr2+4,toRow2=nr2+24;
8801        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>')}});
8802        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8803        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">';
8804        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
8805        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>';
8806        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>';
8807        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8808        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8809        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8810        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8811        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
8812        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
8813        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>')}});
8814        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
8815      }}
8816      var parts=[],offsets=[],total=0;
8817      files.forEach(function(f){{
8818        offsets.push(total);
8819        var nb=s2b(f.name),crc=crc32(f.data);
8820        var h=new DataView(new ArrayBuffer(30+nb.length));
8821        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
8822        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
8823        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
8824        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
8825        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
8826        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
8827        total+=30+nb.length+f.data.length;
8828      }});
8829      var cdStart=total;
8830      files.forEach(function(f,fi){{
8831        var nb=s2b(f.name),crc=crc32(f.data);
8832        var cd=new DataView(new ArrayBuffer(46+nb.length));
8833        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
8834        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
8835        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
8836        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
8837        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
8838        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
8839        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
8840      }});
8841      var cdSz=total-cdStart;
8842      var eocd=new DataView(new ArrayBuffer(22));
8843      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
8844      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
8845      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
8846      parts.push(new Uint8Array(eocd.buffer));
8847      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
8848      var out=new Uint8Array(sz);var off=0;
8849      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
8850      return out.buffer;
8851    }}
8852
8853    function exportPNG(){{
8854      var svgEl=document.querySelector('#chart-wrap svg');
8855      if(!svgEl){{alert('No chart to export yet.');return;}}
8856      var svgStr=new XMLSerializer().serializeToString(svgEl);
8857      var vb=svgEl.viewBox.baseVal,scale=2;
8858      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
8859      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
8860      var url=URL.createObjectURL(blob);
8861      var img=new Image();
8862      img.onload=function(){{
8863        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8864        var ctx=canvas.getContext('2d');
8865        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8866        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8867        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8868        URL.revokeObjectURL(url);
8869        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8870      }};
8871      img.src=url;
8872    }}
8873
8874    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8875      var el=document.getElementById(id);
8876      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8877    }});
8878    rootSel.addEventListener('change',function(){{
8879      populateSubmodules(rootSel.value);
8880      loadAndRender();
8881    }});
8882    if(subSel)subSel.addEventListener('change',loadAndRender);
8883
8884    var xlsxBtn=document.getElementById('export-xlsx-btn');
8885    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8886    var pngBtn=document.getElementById('export-png-btn');
8887    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8888
8889    // ── Clean-up modal ───────────────────────────────────────────────────────
8890    (function(){{
8891      var triggerBtn=document.getElementById('cleanup-runs-btn');
8892      if(!triggerBtn)return;
8893      var modal=document.createElement('div');
8894      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;';
8895      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);">'
8896        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8897        +'<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>'
8898        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8899        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8900        +'<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;">'
8901        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8902        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8903        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8904        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8905        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8906        +'</div></div>';
8907      document.body.appendChild(modal);
8908      triggerBtn.addEventListener('click',function(){{
8909        document.getElementById('cleanup-status').style.display='none';
8910        modal.style.display='flex';
8911      }});
8912      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8913      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8914      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8915        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8916        var confirmBtn=this;
8917        confirmBtn.disabled=true;
8918        var status=document.getElementById('cleanup-status');
8919        status.style.display='block';
8920        status.style.background='#dbeafe';status.style.color='#1e40af';
8921        status.textContent='Deleting\u2026';
8922        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8923        .then(function(resp){{
8924          return resp.json().then(function(d){{
8925            if(resp.ok){{
8926              status.style.background='#dcfce7';status.style.color='#166534';
8927              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8928              setTimeout(function(){{window.location.reload();}},1500);
8929            }}else{{
8930              status.style.background='#fee2e2';status.style.color='#991b1b';
8931              status.textContent='Error: '+(d.error||'Unexpected error');
8932              confirmBtn.disabled=false;
8933            }}
8934          }});
8935        }})
8936        .catch(function(e){{
8937          status.style.background='#fee2e2';status.style.color='#991b1b';
8938          status.textContent='Network error: '+String(e);
8939          confirmBtn.disabled=false;
8940        }});
8941      }});
8942    }})();
8943
8944    // ── Retention policy panel ────────────────────────────────────────────────
8945    (function(){{
8946      var triggerBtn=document.getElementById('retention-policy-btn');
8947      if(!triggerBtn)return;
8948      var modal=document.createElement('div');
8949      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;';
8950      modal.innerHTML=''
8951        +'<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);">'
8952        +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
8953        +'<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>'
8954        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
8955        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
8956        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
8957        +'</div>'
8958        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
8959        +'<div>'
8960        +'<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>'
8961        +'<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;">'
8962        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
8963        +'</div>'
8964        +'<div>'
8965        +'<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>'
8966        +'<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;">'
8967        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
8968        +'</div>'
8969        +'</div>'
8970        +'<div style="margin-bottom:20px;">'
8971        +'<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>'
8972        +'<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;">'
8973        +'<option value="1">Every hour</option>'
8974        +'<option value="6">Every 6 hours</option>'
8975        +'<option value="12">Every 12 hours</option>'
8976        +'<option value="24" selected>Every 24 hours</option>'
8977        +'<option value="48">Every 2 days</option>'
8978        +'<option value="72">Every 3 days</option>'
8979        +'<option value="168">Every week</option>'
8980        +'</select>'
8981        +'</div>'
8982        +'<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>'
8983        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
8984        +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
8985        +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
8986        +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
8987        +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
8988        +'</div>'
8989        +'</div>';
8990      document.body.appendChild(modal);
8991
8992      function rpShowStatus(msg,ok){{
8993        var s=document.getElementById('rp-status');
8994        s.style.display='block';
8995        s.style.background=ok?'#dcfce7':'#fee2e2';
8996        s.style.color=ok?'#166534':'#991b1b';
8997        s.textContent=msg;
8998      }}
8999      function fmtAgo(iso){{
9000        if(!iso)return'Never';
9001        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
9002        if(diff<60)return diff+'s ago';
9003        if(diff<3600)return Math.floor(diff/60)+'m ago';
9004        if(diff<86400)return Math.floor(diff/3600)+'h ago';
9005        return Math.floor(diff/86400)+'d ago';
9006      }}
9007      function loadPolicy(){{
9008        fetch('/api/cleanup-policy')
9009          .then(function(r){{return r.json();}})
9010          .then(function(d){{
9011            var p=d.policy;
9012            document.getElementById('rp-enabled').checked=p?p.enabled:false;
9013            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
9014            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
9015            var sel=document.getElementById('rp-interval');
9016            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;}}}}}}
9017            var lr=document.getElementById('rp-last-run');
9018            if(d.last_run_at){{
9019              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'):'');
9020            }}else{{
9021              lr.textContent='Auto-cleanup has not run yet.';
9022            }}
9023          }})
9024          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
9025      }}
9026
9027      triggerBtn.addEventListener('click',function(){{
9028        document.getElementById('rp-status').style.display='none';
9029        loadPolicy();
9030        modal.style.display='flex';
9031      }});
9032      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
9033      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
9034
9035      document.getElementById('rp-save-btn').addEventListener('click',function(){{
9036        var enabled=document.getElementById('rp-enabled').checked;
9037        var ageVal=document.getElementById('rp-max-age').value.trim();
9038        var countVal=document.getElementById('rp-max-count').value.trim();
9039        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
9040        if(enabled&&!ageVal&&!countVal){{
9041          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
9042          return;
9043        }}
9044        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
9045        var saveBtn=document.getElementById('rp-save-btn');
9046        saveBtn.disabled=true;
9047        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
9048          .then(function(r){{
9049            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
9050            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
9051          }})
9052          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
9053          .finally(function(){{saveBtn.disabled=false;}});
9054      }});
9055
9056      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
9057        var btn=this;
9058        btn.disabled=true;
9059        btn.textContent='Running\u2026';
9060        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
9061          .then(function(r){{return r.json();}})
9062          .then(function(d){{
9063            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
9064            loadPolicy();
9065          }})
9066          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
9067          .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
9068      }});
9069    }})();
9070
9071    populateSubmodules(rootSel.value);
9072    loadAndRender();
9073
9074    (function randomizeWatermarks() {{
9075      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9076      if (!wms.length) return;
9077      var placed = [];
9078      function tooClose(top, left) {{
9079        for (var i = 0; i < placed.length; i++) {{
9080          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
9081          if (dt < 16 && dl < 12) return true;
9082        }}
9083        return false;
9084      }}
9085      function pick(leftBand) {{
9086        for (var attempt = 0; attempt < 50; attempt++) {{
9087          var top = Math.random() * 88 + 2;
9088          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
9089          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
9090        }}
9091        var top = Math.random() * 88 + 2;
9092        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
9093        placed.push([top, left]); return [top, left];
9094      }}
9095      var half = Math.floor(wms.length / 2);
9096      wms.forEach(function (img, i) {{
9097        var pos = pick(i < half);
9098        var size = Math.floor(Math.random() * 100 + 120);
9099        var rot = (Math.random() * 360).toFixed(1);
9100        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
9101        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;
9102      }});
9103    }})();
9104    (function spawnCodeParticles() {{
9105      var container = document.getElementById('code-particles');
9106      if (!container) return;
9107      var snippets = [
9108        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
9109        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
9110        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
9111        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
9112        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
9113      ];
9114      var count = 38;
9115      for (var i = 0; i < count; i++) {{
9116        (function(idx) {{
9117          var el = document.createElement('span');
9118          el.className = 'code-particle';
9119          el.textContent = snippets[idx % snippets.length];
9120          var left = Math.random() * 94 + 2;
9121          var top = Math.random() * 88 + 6;
9122          var dur = (Math.random() * 10 + 9).toFixed(1);
9123          var delay = (Math.random() * 18).toFixed(1);
9124          var rot = (Math.random() * 26 - 13).toFixed(1);
9125          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9126          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
9127          container.appendChild(el);
9128        }})(i);
9129      }}
9130    }})();
9131  </script>
9132  <footer class="site-footer">
9133    local code analysis - metrics, history and reports
9134    &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>
9135    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9136    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9137    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9138    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9139  </footer>
9140  <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>
9141</body>
9142</html>"##,
9143    );
9144
9145    Html(html).into_response()
9146}
9147
9148fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9149    use std::collections::HashMap;
9150    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
9151        return vec![];
9152    }
9153    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9154    for rec in per_file_records {
9155        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9156            let e = totals.entry(lang.display_name().to_string()).or_default();
9157            e.0 += u64::from(cov.lines_found);
9158            e.1 += u64::from(cov.lines_hit);
9159        }
9160    }
9161    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
9162    let mut pairs: Vec<(String, f64)> = totals
9163        .into_iter()
9164        .filter(|(_, (found, _))| *found > 0)
9165        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9166        .collect();
9167    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9168    pairs
9169        .iter()
9170        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
9171        .collect()
9172}
9173
9174fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
9175    let mut high = 0u64;
9176    let mut mid = 0u64;
9177    let mut low = 0u64;
9178    for rec in per_file_records {
9179        if let Some(cov) = &rec.coverage {
9180            if cov.lines_found == 0 {
9181                continue;
9182            }
9183            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
9184            if pct >= 80.0 {
9185                high += 1;
9186            } else if pct >= 50.0 {
9187                mid += 1;
9188            } else {
9189                low += 1;
9190            }
9191        }
9192    }
9193    (high, mid, low)
9194}
9195
9196fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9197    let mut arr: Vec<serde_json::Value> = per_file_records
9198        .iter()
9199        .filter_map(|rec| {
9200            rec.coverage.as_ref().map(|cov| {
9201                let line_pct = if cov.lines_found > 0 {
9202                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
9203                        / 10.0
9204                } else {
9205                    0.0
9206                };
9207                let fn_pct = if cov.functions_found > 0 {
9208                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
9209                        .round()
9210                        / 10.0
9211                } else {
9212                    -1.0
9213                };
9214                serde_json::json!({
9215                    "rel": rec.relative_path,
9216                    "lang": rec.language.map_or("?", |l| l.display_name()),
9217                    "line_pct": line_pct,
9218                    "fn_pct": fn_pct,
9219                    "lhit": cov.lines_hit,
9220                    "lfound": cov.lines_found,
9221                    "fhit": cov.functions_hit,
9222                    "ffound": cov.functions_found,
9223                })
9224            })
9225        })
9226        .collect();
9227    arr.sort_by(|a, b| {
9228        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
9229        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
9230        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
9231    });
9232    arr
9233}
9234
9235#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9236fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
9237    let mut langs: Vec<&sloc_core::LanguageSummary> = run
9238        .totals_by_language
9239        .iter()
9240        .filter(|l| l.test_count > 0)
9241        .collect();
9242    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9243    let lang_tests: Vec<serde_json::Value> = langs
9244        .iter()
9245        .map(|l| {
9246            let d = if l.code_lines > 0 {
9247                l.test_count as f64 / l.code_lines as f64 * 1000.0
9248            } else {
9249                0.0
9250            };
9251            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9252                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9253                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9254        })
9255        .collect();
9256    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
9257    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9258    let t = &run.summary_totals;
9259    let total_tests = t.test_count;
9260    let density = if t.code_lines > 0 {
9261        total_tests as f64 / t.code_lines as f64 * 1000.0
9262    } else {
9263        0.0
9264    };
9265    let most_tested = langs.first().map_or_else(
9266        || "\u{2014}".to_string(),
9267        |l| l.language.display_name().to_string(),
9268    );
9269    let test_files: u64 = run
9270        .per_file_records
9271        .iter()
9272        .filter(|f| f.raw_line_categories.test_count > 0)
9273        .count() as u64;
9274    let cov_line = if t.coverage_lines_found > 0 {
9275        format!(
9276            "{:.1}",
9277            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
9278        )
9279    } else {
9280        "0".to_string()
9281    };
9282    let cov_fn = if t.coverage_functions_found > 0 {
9283        format!(
9284            "{:.1}",
9285            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
9286        )
9287    } else {
9288        "0".to_string()
9289    };
9290    let cov_branch = if t.coverage_branches_found > 0 {
9291        format!(
9292            "{:.1}",
9293            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
9294        )
9295    } else {
9296        "0".to_string()
9297    };
9298    let has_cov = !cov_arr.is_empty();
9299    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
9300    serde_json::json!({
9301        "totals": {
9302            "test_count": total_tests,
9303            "assertions": t.test_assertion_count,
9304            "suites": t.test_suite_count,
9305            "test_files": test_files,
9306            "total_files": t.files_analyzed,
9307            "density_str": format!("{density:.1}"),
9308            "most_tested": most_tested,
9309            "langs_with_tests": langs.len(),
9310            "cov_line": cov_line,
9311            "cov_fn": cov_fn,
9312            "cov_branch": cov_branch,
9313        },
9314        "lang_tests": lang_tests,
9315        "cov": cov_arr,
9316        "cov_tiers": {"high": high, "mid": mid, "low": low},
9317        "file_cov": file_cov_arr,
9318        "has_coverage": has_cov,
9319        "submodules": {},
9320    })
9321}
9322
9323#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9324fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
9325    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
9326        .language_summaries
9327        .iter()
9328        .filter(|l| l.test_count > 0)
9329        .collect();
9330    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9331    let lang_tests: Vec<serde_json::Value> = langs
9332        .iter()
9333        .map(|l| {
9334            let d = if l.code_lines > 0 {
9335                l.test_count as f64 / l.code_lines as f64 * 1000.0
9336            } else {
9337                0.0
9338            };
9339            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9340                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9341                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9342        })
9343        .collect();
9344    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
9345    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
9346    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
9347    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
9348    let density = if sub.code_lines > 0 {
9349        total_tests as f64 / sub.code_lines as f64 * 1000.0
9350    } else {
9351        0.0
9352    };
9353    let most_tested = langs.first().map_or_else(
9354        || "\u{2014}".to_string(),
9355        |l| l.language.display_name().to_string(),
9356    );
9357    serde_json::json!({
9358        "totals": {
9359            "test_count": total_tests,
9360            "assertions": total_assertions,
9361            "suites": total_suites,
9362            "test_files": test_files_approx,
9363            "total_files": sub.files_analyzed,
9364            "density_str": format!("{density:.1}"),
9365            "most_tested": most_tested,
9366            "langs_with_tests": langs.len(),
9367            "cov_line": "0",
9368            "cov_fn": "0",
9369            "cov_branch": "0",
9370        },
9371        "lang_tests": lang_tests,
9372        "cov": [],
9373        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
9374        "has_coverage": false,
9375    })
9376}
9377
9378fn compute_cov_json_str(run: &AnalysisRun) -> String {
9379    use std::collections::HashMap;
9380    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9381    for rec in &run.per_file_records {
9382        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9383            let e = totals.entry(lang.display_name().to_string()).or_default();
9384            e.0 += u64::from(cov.lines_found);
9385            e.1 += u64::from(cov.lines_hit);
9386        }
9387    }
9388    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
9389    let mut pairs: Vec<(String, f64)> = totals
9390        .into_iter()
9391        .filter(|(_, (found, _))| *found > 0)
9392        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9393        .collect();
9394    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9395    let parts: Vec<String> = pairs
9396        .iter()
9397        .map(|(lang, pct)| {
9398            let name = lang.replace('"', "\\\"");
9399            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
9400        })
9401        .collect();
9402    format!("[{}]", parts.join(","))
9403}
9404
9405fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
9406    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9407    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
9408}
9409
9410fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
9411    let mut entry = build_test_scope_entry(run);
9412    if !run.submodule_summaries.is_empty() {
9413        let subs: serde_json::Map<String, serde_json::Value> = run
9414            .submodule_summaries
9415            .iter()
9416            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
9417            .collect();
9418        entry["submodules"] = serde_json::Value::Object(subs);
9419    }
9420    entry
9421}
9422
9423fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
9424    let name = l.language.display_name().replace('"', "\\\"");
9425    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
9426    let density = if l.code_lines > 0 {
9427        l.test_count as f64 / l.code_lines as f64 * 1000.0
9428    } else {
9429        0.0
9430    };
9431    format!(
9432        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
9433        name = name,
9434        t = l.test_count,
9435        a = l.test_assertion_count,
9436        s = l.test_suite_count,
9437        c = l.code_lines,
9438        d = density,
9439        f = l.files,
9440    )
9441}
9442
9443fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
9444    let Some(r) = run else {
9445        return "[]".to_string();
9446    };
9447    let mut langs: Vec<&sloc_core::LanguageSummary> = r
9448        .totals_by_language
9449        .iter()
9450        .filter(|l| l.test_count > 0)
9451        .collect();
9452    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9453    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
9454    format!("[{}]", parts.join(","))
9455}
9456
9457/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
9458async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
9459    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
9460    scope_map.insert(
9461        "__all__".to_string(),
9462        latest_run.map_or_else(
9463            || {
9464                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
9465                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
9466                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
9467                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
9468                    "has_coverage":false,"submodules":{}})
9469            },
9470            build_test_scope_entry,
9471        ),
9472    );
9473    let all_roots: Vec<String> = {
9474        let reg = state.registry.lock().await;
9475        let mut seen = std::collections::BTreeSet::new();
9476        reg.entries
9477            .iter()
9478            .flat_map(|e| e.input_roots.iter().cloned())
9479            .filter(|r| seen.insert(r.clone()))
9480            .collect()
9481    };
9482    for root in &all_roots {
9483        let json_path = {
9484            let reg = state.registry.lock().await;
9485            reg.entries
9486                .iter()
9487                .find(|e| e.input_roots.iter().any(|r| r == root))
9488                .and_then(|e| e.json_path.clone())
9489        };
9490        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
9491            let json_str = tokio::fs::read_to_string(&p).await.ok();
9492            json_str
9493                .as_deref()
9494                .and_then(|s| serde_json::from_str(s).ok())
9495        } else {
9496            None
9497        };
9498        if let Some(ref run) = run_for_root {
9499            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
9500        }
9501    }
9502    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
9503}
9504
9505// GET /test-metrics
9506#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9507#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
9508async fn test_metrics_handler(
9509    State(state): State<AppState>,
9510    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9511) -> Response {
9512    auto_scan_watched_dirs(&state).await;
9513    let watched_dirs_list: Vec<String> = {
9514        let wd = state.watched_dirs.lock().await;
9515        wd.dirs.iter().map(|p| p.display().to_string()).collect()
9516    };
9517    let latest_run: Option<AnalysisRun> = {
9518        let json_path = {
9519            let reg = state.registry.lock().await;
9520            reg.entries.first().and_then(|e| e.json_path.clone())
9521        };
9522        if let Some(p) = json_path {
9523            let json_str = tokio::fs::read_to_string(&p).await.ok();
9524            json_str
9525                .as_deref()
9526                .and_then(|s| serde_json::from_str(s).ok())
9527        } else {
9528            None
9529        }
9530    };
9531
9532    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
9533    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
9534
9535    // Build coverage chart JSON (per-language avg line coverage %).
9536    let cov_json: String = latest_run
9537        .as_ref()
9538        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9539        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
9540
9541    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
9542    let _cov_tier_json: String = latest_run
9543        .as_ref()
9544        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9545        .map_or_else(
9546            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
9547            compute_cov_tier_json_str,
9548        );
9549
9550    let total_tests: u64 = latest_run
9551        .as_ref()
9552        .map_or(0, |r| r.summary_totals.test_count);
9553    let total_assertions: u64 = latest_run
9554        .as_ref()
9555        .map_or(0, |r| r.summary_totals.test_assertion_count);
9556    let total_suites: u64 = latest_run
9557        .as_ref()
9558        .map_or(0, |r| r.summary_totals.test_suite_count);
9559    let total_code: u64 = latest_run
9560        .as_ref()
9561        .map_or(0, |r| r.summary_totals.code_lines);
9562    let workspace_density: f64 = if total_code > 0 {
9563        total_tests as f64 / total_code as f64 * 1000.0
9564    } else {
9565        0.0
9566    };
9567    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
9568        r.totals_by_language
9569            .iter()
9570            .filter(|l| l.test_count > 0)
9571            .count()
9572    });
9573    let most_tested: String = latest_run
9574        .as_ref()
9575        .and_then(|r| {
9576            r.totals_by_language
9577                .iter()
9578                .filter(|l| l.test_count > 0)
9579                .max_by_key(|l| l.test_count)
9580        })
9581        .map_or_else(
9582            || "\u{2014}".to_string(),
9583            |l| l.language.display_name().to_string(),
9584        );
9585    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
9586        r.per_file_records
9587            .iter()
9588            .filter(|f| f.raw_line_categories.test_count > 0)
9589            .count() as u64
9590    });
9591    let total_files_analyzed: u64 = latest_run
9592        .as_ref()
9593        .map_or(0, |r| r.summary_totals.files_analyzed);
9594    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
9595
9596    // Aggregated coverage percentages from summary_totals
9597    let cov_line_pct_str: String = latest_run
9598        .as_ref()
9599        .filter(|r| r.summary_totals.coverage_lines_found > 0)
9600        .map_or_else(
9601            || "0".to_string(),
9602            |r| {
9603                format!(
9604                    "{:.1}",
9605                    r.summary_totals.coverage_lines_hit as f64
9606                        / r.summary_totals.coverage_lines_found as f64
9607                        * 100.0
9608                )
9609            },
9610        );
9611    let cov_fn_pct_str: String = latest_run
9612        .as_ref()
9613        .filter(|r| r.summary_totals.coverage_functions_found > 0)
9614        .map_or_else(
9615            || "0".to_string(),
9616            |r| {
9617                format!(
9618                    "{:.1}",
9619                    r.summary_totals.coverage_functions_hit as f64
9620                        / r.summary_totals.coverage_functions_found as f64
9621                        * 100.0
9622                )
9623            },
9624        );
9625    let cov_branch_pct_str: String = latest_run
9626        .as_ref()
9627        .filter(|r| r.summary_totals.coverage_branches_found > 0)
9628        .map_or_else(
9629            || "0".to_string(),
9630            |r| {
9631                format!(
9632                    "{:.1}",
9633                    r.summary_totals.coverage_branches_hit as f64
9634                        / r.summary_totals.coverage_branches_found as f64
9635                        * 100.0
9636                )
9637            },
9638        );
9639
9640    let cov_no_data_notice = if has_coverage {
9641        String::new()
9642    } else {
9643        String::from(
9644            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
9645<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>
9646<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
9647  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
9648  <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>
9649  <span style="color:var(--muted);font-size:12px;">&middot;</span>
9650  <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>
9651  <span style="color:var(--muted);font-size:12px;">&middot;</span>
9652  <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>
9653</div>
9654<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
9655</div>"#,
9656        )
9657    };
9658
9659    let workspace_density_str = format!("{workspace_density:.1}");
9660    let nonce = &csp_nonce;
9661    let version = env!("CARGO_PKG_VERSION");
9662
9663    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
9664    // of interactive controls — folder watching is managed by the host administrator.
9665    let watched_dirs_html: String = if state.server_mode {
9666        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()
9667    } else {
9668        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
9669            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
9670                .to_string()
9671        } else {
9672            watched_dirs_list
9673                .iter()
9674                .fold(String::new(), |mut s, d| {
9675                    use std::fmt::Write as _;
9676                    let escaped =
9677                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
9678                    write!(
9679                        s,
9680                        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>"#
9681                    ).expect("write to String is infallible");
9682                    s
9683                })
9684        };
9685        format!(
9686            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>"#
9687        )
9688    };
9689
9690    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
9691    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
9692
9693    let html = format!(
9694        r#"<!doctype html>
9695<html lang="en">
9696<head>
9697  <meta charset="utf-8" />
9698  <meta name="viewport" content="width=device-width, initial-scale=1" />
9699  <title>OxideSLOC | Test Metrics</title>
9700  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9701  <style nonce="{nonce}">
9702    :root {{
9703      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9704      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9705      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
9706      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9707      --info-bg:#eef3ff; --info-text:#4467d8;
9708    }}
9709    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
9710    *{{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;}}
9711    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9712    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
9713    .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;}}
9714    @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));}}}}
9715    .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);}}
9716    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
9717    .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));}}
9718    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9719    .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;}}
9720    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
9721    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
9722    @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; }} }}
9723    .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;}}
9724    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9725    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9726    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9727    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
9728    .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;}}
9729    .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;}}
9730    .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;}}
9731    .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;}}
9732    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9733    .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);}}
9734    .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;}}
9735    .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;}}
9736    .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;}}
9737    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9738    .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;}}
9739    .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);}}
9740    .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;}}
9741    .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;}}
9742    .tz-select:focus{{border-color:var(--oxide);}}
9743    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9744    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
9745    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
9746    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
9747    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
9748    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
9749    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
9750    .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;}}
9751    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
9752    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
9753    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
9754    .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;}}
9755    .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;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;}}
9756    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
9757    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
9758    .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);}}
9759    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
9760    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
9761    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
9762    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9763    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
9764    .chart-canvas-wrap{{position:relative;height:280px;}}
9765    .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;}}
9766    .chart-no-data svg{{opacity:0.35;}}
9767    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
9768    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
9769    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9770    .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;}}
9771    .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;}}
9772    .data-table tr:last-child td{{border-bottom:none;}}
9773    .data-table tbody tr:hover td{{background:var(--surface-2);}}
9774    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
9775    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
9776    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
9777    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
9778    .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;}}
9779    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
9780    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
9781    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
9782    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
9783    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
9784    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
9785    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
9786    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
9787    .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;}}
9788    .chart-select:focus{{border-color:var(--accent);}}
9789    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
9790    .trend-canvas-wrap{{position:relative;height:260px;}}
9791    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9792    .site-footer a{{color:var(--muted);}}
9793    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
9794    .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;}}
9795    .btn:hover{{background:var(--surface-2);}}
9796    .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;}}
9797    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9798    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
9799    .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;}}
9800    .scope-sel:focus{{border-color:var(--accent);}}
9801    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
9802    .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;}}
9803    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
9804    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9805    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
9806    .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;}}
9807    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
9808    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
9809    .watched-chip-rm:hover{{color:var(--oxide);}}
9810    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
9811    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
9812    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
9813    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
9814    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
9815    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
9816    .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;}}
9817    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
9818    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
9819    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
9820    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
9821    .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;}}
9822    .cov-file-search:focus{{border-color:var(--accent);}}
9823    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
9824    .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;}}
9825    body.dark-theme .cov-file-search{{background:var(--surface);}}
9826    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9827    .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;}}
9828    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9829    .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;}}
9830    .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);}}
9831    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
9832    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
9833    .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;}}
9834    .chart-modal-close:hover{{opacity:.7;}}
9835    body.dark-theme .chart-modal{{background:var(--surface);}}
9836  </style>
9837</head>
9838<body>
9839  <div class="background-watermarks" aria-hidden="true">
9840    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9841    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9842    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9843    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9844    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9845    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9846  </div>
9847  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9848  <div class="top-nav">
9849    <div class="top-nav-inner">
9850      <a class="brand" href="/">
9851        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9852        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
9853      </a>
9854      <div class="nav-right">
9855        <a class="nav-pill" href="/">Home</a>
9856        <div class="nav-dropdown">
9857          <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>
9858          <div class="nav-dropdown-menu">
9859            <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>
9860          </div>
9861        </div>
9862        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9863        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
9864        <div class="nav-dropdown">
9865          <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>
9866          <div class="nav-dropdown-menu">
9867            <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>
9868          </div>
9869        </div>
9870        <div class="server-status-wrap" id="server-status-wrap">
9871          <div class="nav-pill server-online-pill" id="server-status-pill">
9872            <span class="status-dot" id="status-dot"></span>
9873            <span id="server-status-label">Server</span>
9874            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9875          </div>
9876          <div class="server-status-tip">
9877            OxideSLOC is running — accessible on your network.
9878            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9879          </div>
9880        </div>
9881        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9882          <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>
9883        </button>
9884        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9885          <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>
9886          <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>
9887        </button>
9888      </div>
9889    </div>
9890  </div>
9891
9892  <div class="page">
9893    {watched_dirs_html}
9894    <div class="scope-bar">
9895      <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>
9896      <span class="scope-label">Scope</span>
9897      <div class="scope-sel-wrap">
9898        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
9899        <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);">
9900          <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>
9901          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
9902        </div>
9903      </div>
9904    </div>
9905    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9906      <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>
9907      <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>
9908      <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>
9909      <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>
9910    </div>
9911    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9912      <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>
9913      <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>
9914      <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>
9915      <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>
9916    </div>
9917
9918    <div class="panel" id="viz-panel">
9919      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
9920
9921      <div class="chart-box" style="margin-bottom:18px;">
9922        <div class="chart-box-header">
9923          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
9924          <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9925        </div>
9926        <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope.</p>
9927        <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9928        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9929      </div>
9930
9931      <div class="chart-row">
9932        <div class="chart-box">
9933          <div class="chart-box-header">
9934            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
9935            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9936          </div>
9937          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
9938          <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>
9939        </div>
9940        <div class="chart-box">
9941          <div class="chart-box-header">
9942            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
9943            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9944          </div>
9945          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
9946          <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>
9947        </div>
9948      </div>
9949
9950      <div class="chart-row">
9951        <div class="chart-box">
9952          <div class="chart-box-header">
9953            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
9954            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9955          </div>
9956          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
9957          <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>
9958        </div>
9959        <div class="chart-box" id="suites-chart-box">
9960          <div class="chart-box-header">
9961            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
9962            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9963          </div>
9964          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
9965          <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>
9966        </div>
9967      </div>
9968
9969      <div class="chart-row">
9970        <div class="chart-box">
9971          <div class="chart-box-title">Test Files Breakdown</div>
9972          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
9973          <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>
9974        </div>
9975        <div class="chart-box">
9976          <div class="chart-box-title">Test Composition</div>
9977          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
9978          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
9979          <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>
9980        </div>
9981      </div>
9982    </div>
9983
9984    <div class="panel">
9985      <h1>Test Metrics</h1>
9986      <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>
9987
9988      <div class="section-header">Language Breakdown</div>
9989      {cov_no_data_notice}
9990      <div style="overflow-x:auto;">
9991        <table class="data-table" id="lang-table">
9992          <thead><tr>
9993            <th>Language</th>
9994            <th class="num">Test Fns</th>
9995            <th class="num">Assertions</th>
9996            <th class="num">Suites</th>
9997            <th class="num">Code Lines</th>
9998            <th class="num">Files</th>
9999            <th class="num">Density / 1K</th>
10000            <th>Relative Density</th>
10001          </tr></thead>
10002          <tbody id="lang-tbody"></tbody>
10003        </table>
10004      </div>
10005    </div>
10006
10007    <div class="panel" id="cov-panel" style="display:none;">
10008      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
10009      <div class="cov-gauge-row" id="cov-gauges">
10010        <div class="cov-gauge-card">
10011          <div class="cov-gauge-label">Line Coverage</div>
10012          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
10013          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
10014          <div class="cov-gauge-sub">Lines hit / instrumented</div>
10015        </div>
10016        <div class="cov-gauge-card">
10017          <div class="cov-gauge-label">Function Coverage</div>
10018          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
10019          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
10020          <div class="cov-gauge-sub">Functions hit / found</div>
10021        </div>
10022        <div class="cov-gauge-card">
10023          <div class="cov-gauge-label">Branch Coverage</div>
10024          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
10025          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
10026          <div class="cov-gauge-sub">Branches hit / found</div>
10027        </div>
10028      </div>
10029      <div class="chart-row">
10030        <div class="chart-box">
10031          <div class="chart-box-title">Line Coverage % by Language</div>
10032          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
10033        </div>
10034        <div class="chart-box">
10035          <div class="chart-box-title">Coverage Tier Distribution</div>
10036          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
10037        </div>
10038      </div>
10039
10040      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
10041      <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>
10042      <div class="cov-file-toolbar">
10043        <div class="cov-filter-tabs" id="cov-filter-tabs">
10044          <button class="cov-tab active" data-tier="all">All</button>
10045          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
10046          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
10047          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
10048          <button class="cov-tab" data-tier="high">High (≥80%)</button>
10049        </div>
10050        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
10051      </div>
10052      <div style="overflow-x:auto;">
10053        <table class="data-table" id="cov-file-table">
10054          <thead><tr>
10055            <th>File</th>
10056            <th>Lang</th>
10057            <th class="num">Line %</th>
10058            <th class="num">Lines Hit / Found</th>
10059            <th class="num">Fn %</th>
10060            <th class="num">Fns Hit / Found</th>
10061          </tr></thead>
10062          <tbody id="cov-file-tbody"></tbody>
10063        </table>
10064      </div>
10065      <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>
10066      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
10067    </div>
10068
10069  </div>
10070
10071  <footer class="site-footer">
10072    local code analysis - metrics, history and reports
10073    &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>
10074    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10075    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10076    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10077    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10078  </footer>
10079
10080  <script nonce="{nonce}">
10081  (function() {{
10082    // Theme
10083    var b = document.body;
10084    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10085    var tgl = document.getElementById('theme-toggle');
10086    if (tgl) tgl.addEventListener('click', function() {{
10087      var d = b.classList.toggle('dark-theme');
10088      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10089    }});
10090
10091    // Watermarks
10092    (function() {{
10093      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10094      if (!wms.length) return;
10095      var placed = [];
10096      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;}}
10097      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];}}
10098      var half=Math.floor(wms.length/2);
10099      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;}});
10100    }})();
10101
10102    // Code particles
10103    (function() {{
10104      var container = document.getElementById('code-particles');
10105      if (!container) return;
10106      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
10107      for (var i = 0; i < 36; i++) {{
10108        (function(idx) {{
10109          var el = document.createElement('span');
10110          el.className = 'code-particle';
10111          el.textContent = snippets[idx % snippets.length];
10112          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10113          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10114          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10115          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';
10116          container.appendChild(el);
10117        }})(i);
10118      }}
10119    }})();
10120
10121    // Settings modal
10122    (function() {{
10123      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'}}];
10124      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);}});}}
10125      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10126      var btn=document.getElementById('settings-btn');if(!btn)return;
10127      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10128      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>';
10129      document.body.appendChild(m);
10130      var g=document.getElementById('scheme-grid');
10131      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);}});
10132      var cl=document.getElementById('settings-close');
10133      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');}});
10134      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10135      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10136    }})();
10137
10138    // Watched folder picker
10139    (function() {{
10140      var btn = document.getElementById('add-watched-btn');
10141      if (!btn) return;
10142      btn.addEventListener('click', function() {{
10143        fetch('/pick-directory?kind=reports')
10144          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10145          .then(function(data) {{
10146            if (!data.cancelled && data.selected_path) {{
10147              var form = document.createElement('form');
10148              form.method = 'POST';
10149              form.action = '/watched-dirs/add';
10150              var ri = document.createElement('input');
10151              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10152              var fi = document.createElement('input');
10153              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10154              form.appendChild(ri); form.appendChild(fi);
10155              document.body.appendChild(form);
10156              form.submit();
10157            }}
10158          }})
10159          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10160      }});
10161    }})();
10162  }})();
10163  </script>
10164
10165  <script src="/static/chart.js" nonce="{nonce}"></script>
10166  <script nonce="{nonce}">
10167  (function() {{
10168    var SCOPE_DATA = {scope_data_json};
10169    var currentRoot = '__all__';
10170    var currentSub  = '';
10171    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
10172    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
10173    var ALL_CHARTS = [];
10174    var currentLangTests = [];
10175    var currentTrendPts = [];
10176
10177    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();}}
10178    function fmtFull(n){{return Number(n).toLocaleString();}}
10179    function isDark(){{return document.body.classList.contains('dark-theme');}}
10180    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
10181    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
10182    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
10183
10184    function makeDlPlugin(fmtFn, anchor) {{
10185      return {{
10186        afterDatasetsDraw: function(chart) {{
10187          var ctx = chart.ctx;
10188          var tc = txtClr();
10189          chart.data.datasets.forEach(function(ds, di) {{
10190            var meta = chart.getDatasetMeta(di);
10191            meta.data.forEach(function(el, idx) {{
10192              var label = fmtFn(ds.data[idx], di, idx);
10193              if (label == null || label === '') return;
10194              ctx.save();
10195              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
10196              ctx.fillStyle = tc;
10197              if (anchor === 'top') {{
10198                ctx.textAlign = 'center';
10199                ctx.textBaseline = 'bottom';
10200                ctx.fillText(String(label), el.x, el.y - 5);
10201              }} else {{
10202                ctx.textAlign = 'left';
10203                ctx.textBaseline = 'middle';
10204                ctx.fillText(String(label), el.x + 5, el.y);
10205              }}
10206              ctx.restore();
10207            }});
10208          }});
10209        }}
10210      }};
10211    }}
10212
10213    function makeTmOverlay(title, subtitle, h) {{
10214      var overlay = document.createElement('div');
10215      overlay.className = 'chart-modal-overlay';
10216      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
10217      var ch = Math.min(h || 560, maxH);
10218      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
10219      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>';
10220      document.body.appendChild(overlay);
10221      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
10222      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
10223      return document.getElementById('tm-modal-canvas');
10224    }}
10225
10226    function getDataset() {{
10227      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10228      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
10229      return r;
10230    }}
10231    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
10232
10233    function showNoData(id, show) {{
10234      var el = document.getElementById(id);
10235      if (!el) return;
10236      var wrap = el.previousElementSibling;
10237      el.style.display = show ? '' : 'none';
10238      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
10239    }}
10240
10241    function renderTestCharts(D) {{
10242      currentLangTests = D || [];
10243      testsChart = destroyChart(testsChart);
10244      densityChart = destroyChart(densityChart);
10245      if (!D || !D.length) {{
10246        showNoData('no-data-tests', true);
10247        showNoData('no-data-density', true);
10248        return;
10249      }}
10250      showNoData('no-data-tests', false);
10251      showNoData('no-data-density', false);
10252      var top15 = D.slice(0, 15);
10253      var canvas1 = document.getElementById('canvas-tests');
10254      if (canvas1) {{
10255        testsChart = new Chart(canvas1, {{
10256          type: 'bar',
10257          data: {{
10258            labels: top15.map(function(d){{ return d.lang; }}),
10259            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10260          }},
10261          options: {{
10262            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10263            layout: {{ padding: {{ right: 64 }} }},
10264            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10265            scales: {{
10266              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10267              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10268            }}
10269          }},
10270          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10271        }});
10272        ALL_CHARTS.push(testsChart);
10273      }}
10274      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
10275      var canvas2 = document.getElementById('canvas-density');
10276      if (canvas2) {{
10277        densityChart = new Chart(canvas2, {{
10278          type: 'bar',
10279          data: {{
10280            labels: topD.map(function(d){{ return d.lang; }}),
10281            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 }}]
10282          }},
10283          options: {{
10284            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10285            layout: {{ padding: {{ right: 64 }} }},
10286            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10287            scales: {{
10288              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10289              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10290            }}
10291          }},
10292          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10293        }});
10294        ALL_CHARTS.push(densityChart);
10295      }}
10296    }}
10297
10298    function renderAssertionsChart(D) {{
10299      assertionsChart = destroyChart(assertionsChart);
10300      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
10301      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10302      var canvas = document.getElementById('canvas-assertions');
10303      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
10304      showNoData('no-data-assertions', false);
10305      assertionsChart = new Chart(canvas, {{
10306        type: 'bar',
10307        data: {{
10308          labels: top15.map(function(d){{ return d.lang; }}),
10309          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10310        }},
10311        options: {{
10312          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10313          layout: {{ padding: {{ right: 64 }} }},
10314          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10315          scales: {{
10316            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10317            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10318          }}
10319        }},
10320        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10321      }});
10322      ALL_CHARTS.push(assertionsChart);
10323    }}
10324
10325    function renderSuitesChart(D) {{
10326      suitesChart = destroyChart(suitesChart);
10327      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
10328      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10329      var canvas = document.getElementById('canvas-suites');
10330      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
10331      showNoData('no-data-suites', false);
10332      suitesChart = new Chart(canvas, {{
10333        type: 'bar',
10334        data: {{
10335          labels: top15.map(function(d){{ return d.lang; }}),
10336          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 }}]
10337        }},
10338        options: {{
10339          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10340          layout: {{ padding: {{ right: 64 }} }},
10341          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10342          scales: {{
10343            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10344            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10345          }}
10346        }},
10347        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10348      }});
10349      ALL_CHARTS.push(suitesChart);
10350    }}
10351
10352    function renderFilesChart(totals) {{
10353      filesChart = destroyChart(filesChart);
10354      var canvas = document.getElementById('canvas-files');
10355      if (!canvas) return;
10356      var testF = totals.test_files || 0;
10357      var totalF = totals.total_files || 0;
10358      var nonTest = Math.max(0, totalF - testF);
10359      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
10360      showNoData('no-data-files', false);
10361      var dark = isDark();
10362      filesChart = new Chart(canvas, {{
10363        type: 'doughnut',
10364        data: {{
10365          labels: ['Test Files', 'Non-Test Files'],
10366          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
10367        }},
10368        options: {{
10369          responsive: true, maintainAspectRatio: false, cutout: '62%',
10370          plugins: {{
10371            legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10372            tooltip: {{ callbacks: {{ label: function(ctx) {{
10373              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
10374              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
10375            }} }} }}
10376          }}
10377        }}
10378      }});
10379      ALL_CHARTS.push(filesChart);
10380    }}
10381
10382    function renderCompositionChart(totals) {{
10383      compositionChart = destroyChart(compositionChart);
10384      var canvas = document.getElementById('canvas-composition');
10385      if (!canvas) return;
10386      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
10387      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
10388      showNoData('no-data-composition', false);
10389      compositionChart = new Chart(canvas, {{
10390        type: 'bar',
10391        data: {{
10392          labels: ['Test Functions', 'Assertions', 'Test Suites'],
10393          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
10394        }},
10395        options: {{
10396          responsive: true, maintainAspectRatio: false,
10397          layout: {{ padding: {{ top: 22 }} }},
10398          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
10399          scales: {{
10400            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
10401            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10402          }}
10403        }},
10404        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10405      }});
10406      ALL_CHARTS.push(compositionChart);
10407    }}
10408
10409    function renderCovCharts(covD, tiers) {{
10410      covChart = destroyChart(covChart);
10411      tierChart = destroyChart(tierChart);
10412      var covCanvas = document.getElementById('canvas-cov');
10413      if (covCanvas && covD && covD.length) {{
10414        covChart = new Chart(covCanvas, {{
10415          type: 'bar',
10416          data: {{
10417            labels: covD.map(function(d){{ return d.lang; }}),
10418            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 }}]
10419          }},
10420          options: {{
10421            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10422            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
10423            scales: {{
10424              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
10425              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10426            }}
10427          }}
10428        }});
10429        ALL_CHARTS.push(covChart);
10430      }}
10431      var tierCanvas = document.getElementById('canvas-cov-tiers');
10432      if (tierCanvas && tiers) {{
10433        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
10434        tierChart = new Chart(tierCanvas, {{
10435          type: 'doughnut',
10436          data: {{
10437            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
10438            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
10439          }},
10440          options: {{
10441            responsive: true, maintainAspectRatio: false, cutout: '62%',
10442            plugins: {{
10443              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10444              tooltip: {{ callbacks: {{ label: function(ctx) {{
10445                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
10446                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
10447              }} }} }}
10448            }}
10449          }}
10450        }});
10451        ALL_CHARTS.push(tierChart);
10452      }}
10453    }}
10454
10455    function buildLangTable(D) {{
10456      var tbody = document.getElementById('lang-tbody');
10457      if (!tbody) return;
10458      if (!D || !D.length) {{
10459        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>';
10460        return;
10461      }}
10462      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
10463      tbody.innerHTML = D.map(function(d) {{
10464        var barW = Math.round(d.density / maxDensity * 120);
10465        return '<tr>' +
10466          '<td><strong>' + d.lang + '</strong></td>' +
10467          '<td class="num">' + fmt(d.tests) + '</td>' +
10468          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
10469          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
10470          '<td class="num">' + fmt(d.code) + '</td>' +
10471          '<td class="num">' + fmt(d.files) + '</td>' +
10472          '<td class="num">' + d.density.toFixed(2) + '</td>' +
10473          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
10474          '</tr>';
10475      }}).join('');
10476    }}
10477
10478    var covFileData = [];
10479    var covFileTier = 'all';
10480    var covFileSearch = '';
10481
10482    function pctBadge(pct) {{
10483      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
10484      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
10485      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
10486    }}
10487
10488    function buildCovFileTable() {{
10489      var tbody = document.getElementById('cov-file-tbody');
10490      var empty = document.getElementById('cov-file-empty');
10491      var count = document.getElementById('cov-file-count');
10492      if (!tbody) return;
10493      var srch = covFileSearch.toLowerCase();
10494      var filtered = covFileData.filter(function(f) {{
10495        if (covFileTier === 'zero' && f.line_pct > 0) return false;
10496        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
10497        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
10498        if (covFileTier === 'high' && f.line_pct < 80) return false;
10499        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
10500        return true;
10501      }});
10502      if (!filtered.length) {{
10503        tbody.innerHTML = '';
10504        if (empty) empty.style.display = '';
10505        if (count) count.textContent = '';
10506        return;
10507      }}
10508      if (empty) empty.style.display = 'none';
10509      var shown = Math.min(filtered.length, 500);
10510      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
10511      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
10512        var fnCol = f.fn_pct < 0
10513          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
10514          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
10515        return '<tr>' +
10516          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
10517          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
10518          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
10519          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
10520          fnCol +
10521          '</tr>';
10522      }}).join('');
10523    }}
10524
10525    (function() {{
10526      var tabs = document.getElementById('cov-filter-tabs');
10527      if (tabs) {{
10528        tabs.addEventListener('click', function(e) {{
10529          var btn = e.target.closest('.cov-tab');
10530          if (!btn) return;
10531          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
10532          btn.classList.add('active');
10533          covFileTier = btn.getAttribute('data-tier');
10534          buildCovFileTable();
10535        }});
10536      }}
10537      var srch = document.getElementById('cov-file-search');
10538      if (srch) {{
10539        srch.addEventListener('input', function() {{
10540          covFileSearch = this.value;
10541          buildCovFileTable();
10542        }});
10543      }}
10544    }})();
10545
10546    function updateCovGauges(t) {{
10547      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
10548      var el;
10549      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
10550      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
10551      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
10552      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
10553      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
10554      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
10555    }}
10556
10557    function applyScope() {{
10558      var d = getDataset();
10559      var t = d.totals;
10560      var el;
10561      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
10562      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
10563      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
10564      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
10565      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
10566      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
10567      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
10568      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
10569      renderTestCharts(d.lang_tests);
10570      renderAssertionsChart(d.lang_tests);
10571      renderSuitesChart(d.lang_tests);
10572      renderFilesChart(t);
10573      renderCompositionChart(t);
10574      buildLangTable(d.lang_tests);
10575      var covPanel = document.getElementById('cov-panel');
10576      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
10577      if (d.has_coverage) {{
10578        renderCovCharts(d.cov, d.cov_tiers);
10579        updateCovGauges(t);
10580        covFileData = d.file_cov || [];
10581        covFileTier = 'all';
10582        covFileSearch = '';
10583        var tabs = document.getElementById('cov-filter-tabs');
10584        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
10585        var srch = document.getElementById('cov-file-search');
10586        if (srch) srch.value = '';
10587        buildCovFileTable();
10588      }}
10589      loadTrend();
10590    }}
10591
10592    // Populate scope-root-sel from SCOPE_DATA keys
10593    (function() {{
10594      var sel = document.getElementById('scope-root-sel');
10595      if (!sel) return;
10596      Object.keys(SCOPE_DATA).forEach(function(k) {{
10597        if (k === '__all__') return;
10598        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
10599      }});
10600    }})();
10601
10602    document.getElementById('scope-root-sel').addEventListener('change', function() {{
10603      currentRoot = this.value;
10604      currentSub = '';
10605      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10606      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
10607      var subWrap = document.getElementById('scope-sub-wrap');
10608      var subSel  = document.getElementById('scope-sub-sel');
10609      subSel.innerHTML = '<option value="">Entire project</option>';
10610      if (subNames.length) {{
10611        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
10612        subWrap.style.display = 'flex';
10613      }} else {{
10614        subWrap.style.display = 'none';
10615      }}
10616      applyScope();
10617    }});
10618
10619    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
10620      currentSub = this.value;
10621      applyScope();
10622    }});
10623
10624    function buildTrend(data) {{
10625      var trendCanvas = document.getElementById('canvas-trend');
10626      var trendEmpty  = document.getElementById('trend-empty');
10627      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
10628      pts = pts.slice().reverse();
10629      currentTrendPts = pts;
10630      if (!pts.length) {{
10631        if (trendCanvas) trendCanvas.style.display = 'none';
10632        if (trendEmpty) trendEmpty.style.display = '';
10633        return;
10634      }}
10635      if (trendCanvas) trendCanvas.style.display = '';
10636      if (trendEmpty) trendEmpty.style.display = 'none';
10637      trendChart = destroyChart(trendChart);
10638      if (!trendCanvas) return;
10639      trendChart = new Chart(trendCanvas, {{
10640        type: 'line',
10641        data: {{
10642          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10643          datasets: [{{
10644            label: 'Test Definitions',
10645            data: pts.map(function(d){{ return d.test_count; }}),
10646            borderColor: '#C45C10',
10647            backgroundColor: 'rgba(196,92,16,0.10)',
10648            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10649            pointRadius: 5, fill: true, tension: 0.3
10650          }}]
10651        }},
10652        options: {{
10653          responsive: true, maintainAspectRatio: false,
10654          layout: {{ padding: {{ top: 22 }} }},
10655          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10656          scales: {{
10657            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
10658            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10659          }}
10660        }},
10661        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10662      }});
10663      ALL_CHARTS.push(trendChart);
10664    }}
10665
10666    // ── Full View expand buttons ──────────────────────────────────────────────
10667    (function() {{
10668      var btn = document.getElementById('tests-expand-btn');
10669      if (!btn) return;
10670      btn.addEventListener('click', function() {{
10671        var D = currentLangTests;
10672        if (!D || !D.length) return;
10673        var top15 = D.slice(0, 15);
10674        var h = Math.max(320, top15.length * 36 + 80);
10675        var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
10676        if (!canvas) return;
10677        new Chart(canvas, {{
10678          type: 'bar',
10679          data: {{
10680            labels: top15.map(function(d){{ return d.lang; }}),
10681            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10682          }},
10683          options: {{
10684            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10685            layout: {{ padding: {{ right: 72 }} }},
10686            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10687            scales: {{
10688              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10689              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10690            }}
10691          }},
10692          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10693        }});
10694      }});
10695    }})();
10696
10697    (function() {{
10698      var btn = document.getElementById('density-expand-btn');
10699      if (!btn) return;
10700      btn.addEventListener('click', function() {{
10701        var D = currentLangTests;
10702        if (!D || !D.length) return;
10703        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
10704        var h = Math.max(320, topD.length * 36 + 80);
10705        var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
10706        if (!canvas) return;
10707        new Chart(canvas, {{
10708          type: 'bar',
10709          data: {{
10710            labels: topD.map(function(d){{ return d.lang; }}),
10711            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 }}]
10712          }},
10713          options: {{
10714            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10715            layout: {{ padding: {{ right: 72 }} }},
10716            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10717            scales: {{
10718              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10719              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10720            }}
10721          }},
10722          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10723        }});
10724      }});
10725    }})();
10726
10727    (function() {{
10728      var btn = document.getElementById('trend-expand-btn');
10729      if (!btn) return;
10730      btn.addEventListener('click', function() {{
10731        var pts = currentTrendPts;
10732        if (!pts || !pts.length) return;
10733        var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
10734        if (!canvas) return;
10735        new Chart(canvas, {{
10736          type: 'line',
10737          data: {{
10738            labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10739            datasets: [{{
10740              label: 'Test Definitions',
10741              data: pts.map(function(d){{ return d.test_count; }}),
10742              borderColor: '#C45C10',
10743              backgroundColor: 'rgba(196,92,16,0.10)',
10744              pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10745              pointRadius: 5, fill: true, tension: 0.3
10746            }}]
10747          }},
10748          options: {{
10749            responsive: true, maintainAspectRatio: false,
10750            layout: {{ padding: {{ top: 22 }} }},
10751            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10752            scales: {{
10753              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
10754              y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10755            }}
10756          }},
10757          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10758        }});
10759      }});
10760    }})();
10761
10762    (function() {{
10763      var btn = document.getElementById('assertions-expand-btn');
10764      if (!btn) return;
10765      btn.addEventListener('click', function() {{
10766        var D = currentLangTests;
10767        if (!D || !D.length) return;
10768        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10769        if (!top15.length) return;
10770        var h = Math.max(320, top15.length * 36 + 80);
10771        var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
10772        if (!canvas) return;
10773        new Chart(canvas, {{
10774          type: 'bar',
10775          data: {{
10776            labels: top15.map(function(d){{ return d.lang; }}),
10777            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10778          }},
10779          options: {{
10780            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10781            layout: {{ padding: {{ right: 72 }} }},
10782            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10783            scales: {{
10784              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10785              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10786            }}
10787          }},
10788          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10789        }});
10790      }});
10791    }})();
10792
10793    (function() {{
10794      var btn = document.getElementById('suites-expand-btn');
10795      if (!btn) return;
10796      btn.addEventListener('click', function() {{
10797        var D = currentLangTests;
10798        if (!D || !D.length) return;
10799        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10800        if (!top15.length) return;
10801        var h = Math.max(320, top15.length * 36 + 80);
10802        var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
10803        if (!canvas) return;
10804        new Chart(canvas, {{
10805          type: 'bar',
10806          data: {{
10807            labels: top15.map(function(d){{ return d.lang; }}),
10808            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 }}]
10809          }},
10810          options: {{
10811            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10812            layout: {{ padding: {{ right: 72 }} }},
10813            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10814            scales: {{
10815              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10816              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10817            }}
10818          }},
10819          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10820        }});
10821      }});
10822    }})();
10823
10824    function loadTrend() {{
10825      var url = '/api/metrics/history?limit=100';
10826      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
10827      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
10828        buildTrend(data);
10829      }}).catch(function(){{
10830        var trendEmpty = document.getElementById('trend-empty');
10831        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
10832      }});
10833    }}
10834
10835    // Re-render charts on theme toggle
10836    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
10837      setTimeout(function() {{
10838        ALL_CHARTS.forEach(function(c) {{
10839          if (c && c.options && c.options.scales) {{
10840            Object.values(c.options.scales).forEach(function(ax) {{
10841              if (ax.grid) ax.grid.color = clr();
10842              if (ax.ticks) ax.ticks.color = txtClr();
10843            }});
10844            c.update();
10845          }}
10846        }});
10847      }}, 80);
10848    }});
10849
10850    applyScope();
10851  }})();
10852  </script>
10853  <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>
10854</body>
10855</html>"#,
10856    );
10857    Html(html).into_response()
10858}
10859
10860// ── Embeddable widget ─────────────────────────────────────────────────────────
10861// Protected. Returns a self-contained HTML page suitable for iframing inside
10862// Jenkins build summaries, Confluence iframe macros, or Jira panels.
10863//
10864// GET /embed/summary?run_id=<uuid>&theme=dark
10865
10866#[derive(Deserialize)]
10867struct EmbedQuery {
10868    run_id: Option<String>,
10869    theme: Option<String>,
10870}
10871
10872async fn embed_handler(
10873    State(state): State<AppState>,
10874    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10875    Query(query): Query<EmbedQuery>,
10876) -> Response {
10877    let entry = {
10878        let reg = state.registry.lock().await;
10879        query.run_id.as_ref().map_or_else(
10880            || reg.entries.first().cloned(),
10881            |id| reg.find_by_run_id(id).cloned(),
10882        )
10883    };
10884
10885    let Some(entry) = entry else {
10886        return Html(
10887            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
10888                .to_string(),
10889        )
10890        .into_response();
10891    };
10892
10893    let dark = query.theme.as_deref() == Some("dark");
10894    let languages: Vec<(String, u64, u64)> = entry
10895        .json_path
10896        .as_ref()
10897        .and_then(|p| read_json(p).ok())
10898        .map(|run| {
10899            run.totals_by_language
10900                .iter()
10901                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
10902                .collect()
10903        })
10904        .unwrap_or_default();
10905
10906    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
10907}
10908
10909fn render_embed_widget(
10910    entry: &RegistryEntry,
10911    languages: &[(String, u64, u64)],
10912    dark: bool,
10913    csp_nonce: &str,
10914) -> String {
10915    let s = &entry.summary;
10916    let total = s.code_lines + s.comment_lines + s.blank_lines;
10917    let code_pct = s
10918        .code_lines
10919        .checked_mul(100)
10920        .and_then(|n| n.checked_div(total))
10921        .unwrap_or(0);
10922
10923    let (bg, fg, surface, muted, border) = if dark {
10924        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
10925    } else {
10926        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
10927    };
10928
10929    let mut lang_rows = String::new();
10930    for (name, files, code) in languages {
10931        write!(
10932            lang_rows,
10933            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
10934            escape_html(name),
10935            format_number(*files),
10936            format_number(*code),
10937        )
10938        .ok();
10939    }
10940
10941    let lang_table = if lang_rows.is_empty() {
10942        String::new()
10943    } else {
10944        format!(
10945            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
10946        )
10947    };
10948
10949    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
10950    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
10951    let project_esc = escape_html(&entry.project_label);
10952    let code_lines = format_number(s.code_lines);
10953    let comment_lines = format_number(s.comment_lines);
10954    let files = format_number(s.files_analyzed);
10955    let code_raw = s.code_lines;
10956    let comment_raw = s.comment_lines;
10957    let blank_raw = s.blank_lines;
10958
10959    format!(
10960        r#"<!doctype html>
10961<html lang="en">
10962<head>
10963  <meta charset="utf-8">
10964  <meta name="viewport" content="width=device-width,initial-scale=1">
10965  <title>OxideSLOC &mdash; {project_esc}</title>
10966  <script src="/static/chart.js"></script>
10967  <style nonce="{csp_nonce}">
10968    *{{box-sizing:border-box;margin:0;padding:0}}
10969    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
10970    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
10971    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
10972    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
10973    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
10974    .card .v{{font-size:18px;font-weight:700}}
10975    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
10976    .row{{display:flex;gap:12px;align-items:flex-start}}
10977    .pie{{width:120px;height:120px;flex-shrink:0}}
10978    .lt{{border-collapse:collapse;width:100%;flex:1}}
10979    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
10980    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
10981    .n{{text-align:right}}
10982    .footer{{margin-top:10px;color:{muted};font-size:10px}}
10983  </style>
10984</head>
10985<body>
10986  <h2>{project_esc}</h2>
10987  <div class="sub">{timestamp} &middot; run {run_short}</div>
10988  <div class="cards">
10989    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
10990    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
10991    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
10992    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
10993  </div>
10994  <div class="row">
10995    <canvas class="pie" id="c"></canvas>
10996    {lang_table}
10997  </div>
10998  <div class="footer">oxide-sloc</div>
10999  <script nonce="{csp_nonce}">
11000    new Chart(document.getElementById('c'),{{
11001      type:'doughnut',
11002      data:{{
11003        labels:['Code','Comments','Blank'],
11004        datasets:[{{
11005          data:[{code_raw},{comment_raw},{blank_raw}],
11006          backgroundColor:['#4a78ee','#b35428','#aaa'],
11007          borderWidth:0
11008        }}]
11009      }},
11010      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
11011    }});
11012  </script>
11013</body>
11014</html>"#
11015    )
11016}
11017
11018#[allow(clippy::too_many_lines)]
11019fn persist_run_artifacts(
11020    run: &sloc_core::AnalysisRun,
11021    report_html: &str,
11022    run_dir: &Path,
11023    report_title: &str,
11024    file_stem: &str,
11025    result_context: RunResultContext,
11026) -> Result<(RunArtifacts, PendingPdf)> {
11027    // Root dir + organised subdirectories.
11028    let html_dir = run_dir.join("html");
11029    let pdf_dir = run_dir.join("pdf");
11030    let excel_dir = run_dir.join("excel");
11031    let json_dir = run_dir.join("json");
11032    let submodules_dir = run_dir.join("submodules");
11033    for dir in &[
11034        run_dir,
11035        &html_dir,
11036        &pdf_dir,
11037        &excel_dir,
11038        &json_dir,
11039        &submodules_dir,
11040    ] {
11041        fs::create_dir_all(dir)
11042            .with_context(|| format!("failed to create directory {}", dir.display()))?;
11043    }
11044
11045    // HTML report in html/.
11046    let html_path = {
11047        let path = html_dir.join(format!("report_{file_stem}.html"));
11048        fs::write(&path, report_html)
11049            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
11050        Some(path)
11051    };
11052
11053    // JSON result in json/.
11054    let json_path = {
11055        let path = json_dir.join(format!("result_{file_stem}.json"));
11056        let json = serde_json::to_string_pretty(run)
11057            .context("failed to serialize analysis run to JSON")?;
11058        fs::write(&path, json)
11059            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
11060        Some(path)
11061    };
11062
11063    // PDF in pdf/.
11064    let (pdf_path, pending_pdf) = {
11065        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
11066        match write_pdf_from_run(run, &pdf_dest) {
11067            Ok(()) => {
11068                eprintln!(
11069                    "[oxide-sloc][pdf] native PDF written to {}",
11070                    pdf_dest.display()
11071                );
11072                (Some(pdf_dest), None)
11073            }
11074            Err(native_err) => {
11075                eprintln!(
11076                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
11077                );
11078                let source_html_path = html_path
11079                    .as_ref()
11080                    .expect("html_path always Some here")
11081                    .clone();
11082                let pending = Some((source_html_path, pdf_dest.clone(), false));
11083                (Some(pdf_dest), pending)
11084            }
11085        }
11086    };
11087
11088    // CSV and XLSX in excel/.
11089    let csv_path = {
11090        let path = excel_dir.join(format!("report_{file_stem}.csv"));
11091        if let Err(e) = sloc_report::write_csv(run, &path) {
11092            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
11093            None
11094        } else {
11095            Some(path)
11096        }
11097    };
11098
11099    let xlsx_path = {
11100        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
11101        if let Err(e) = sloc_report::write_xlsx(run, &path) {
11102            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
11103            None
11104        } else {
11105            Some(path)
11106        }
11107    };
11108
11109    // Scan config in json/.
11110    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
11111
11112    // Eagerly generate sub-reports before index.html so relative links work.
11113    if run.effective_configuration.discovery.submodule_breakdown {
11114        let run_id = &run.tool.run_id;
11115        for s in &run.submodule_summaries {
11116            build_submodule_row(s, run, run_id, run_dir);
11117        }
11118    }
11119
11120    // index.html at root — offline static export of the result-page dashboard.
11121    generate_offline_index(
11122        run,
11123        run_dir,
11124        file_stem,
11125        html_path.as_deref(),
11126        pdf_path.as_deref(),
11127        json_path.as_deref(),
11128        scan_config_path.as_deref(),
11129        &result_context,
11130    );
11131
11132    Ok((
11133        RunArtifacts {
11134            output_dir: run_dir.to_path_buf(),
11135            html_path,
11136            pdf_path,
11137            json_path,
11138            csv_path,
11139            xlsx_path,
11140            scan_config_path,
11141            report_title: report_title.to_string(),
11142            result_context,
11143        },
11144        pending_pdf,
11145    ))
11146}
11147
11148/// Render a static offline result-page dashboard and write it as `index.html` at
11149/// the root of the run output directory so business users can open it from disk.
11150#[allow(clippy::too_many_arguments)]
11151#[allow(clippy::too_many_lines)]
11152#[allow(clippy::similar_names)]
11153fn generate_offline_index(
11154    run: &sloc_core::AnalysisRun,
11155    run_dir: &Path,
11156    file_stem: &str,
11157    html_path: Option<&Path>,
11158    pdf_path: Option<&Path>,
11159    json_path: Option<&Path>,
11160    scan_config_path: Option<&Path>,
11161    result_context: &RunResultContext,
11162) {
11163    let prev_entry = &result_context.prev_entry;
11164    let prev_scan_count = result_context.prev_scan_count;
11165    let project_path = &result_context.project_path;
11166
11167    let scan_delta = prev_entry.as_ref().and_then(|prev| {
11168        prev.json_path
11169            .as_ref()
11170            .and_then(|p| read_json(p).ok())
11171            .map(|prev_run| compute_delta(&prev_run, run))
11172    });
11173
11174    let files_analyzed = run.per_file_records.len() as u64;
11175    let files_skipped = run.skipped_file_records.len() as u64;
11176    let physical_lines = run
11177        .totals_by_language
11178        .iter()
11179        .map(|r| r.total_physical_lines)
11180        .sum::<u64>();
11181    let code_lines = run
11182        .totals_by_language
11183        .iter()
11184        .map(|r| r.code_lines)
11185        .sum::<u64>();
11186    let comment_lines = run
11187        .totals_by_language
11188        .iter()
11189        .map(|r| r.comment_lines)
11190        .sum::<u64>();
11191    let blank_lines = run
11192        .totals_by_language
11193        .iter()
11194        .map(|r| r.blank_lines)
11195        .sum::<u64>();
11196    let mixed_lines = run
11197        .totals_by_language
11198        .iter()
11199        .map(|r| r.mixed_lines_separate)
11200        .sum::<u64>();
11201    let functions = run
11202        .totals_by_language
11203        .iter()
11204        .map(|r| r.functions)
11205        .sum::<u64>();
11206    let classes = run
11207        .totals_by_language
11208        .iter()
11209        .map(|r| r.classes)
11210        .sum::<u64>();
11211    let variables = run
11212        .totals_by_language
11213        .iter()
11214        .map(|r| r.variables)
11215        .sum::<u64>();
11216    let imports = run
11217        .totals_by_language
11218        .iter()
11219        .map(|r| r.imports)
11220        .sum::<u64>();
11221
11222    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
11223    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
11224    let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
11225    let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
11226    let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
11227    let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
11228    let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
11229    let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
11230
11231    let (delta_fa_str, delta_fa_class) =
11232        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
11233    let (delta_fs_str, delta_fs_class) =
11234        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
11235    let (delta_pl_str, delta_pl_class) =
11236        summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
11237    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
11238    let (delta_cml_str, delta_cml_class) =
11239        summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
11240    let (delta_bl_str, delta_bl_class) =
11241        summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
11242
11243    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
11244    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
11245    let (delta_lines_net_str, delta_lines_net_class) =
11246        match (delta_lines_added, delta_lines_removed) {
11247            (Some(a), Some(r)) => {
11248                let net = a - r;
11249                (fmt_delta(net), delta_class(net).to_string())
11250            }
11251            _ => ("\u{2014}".to_string(), "na".to_string()),
11252        };
11253
11254    let git_commit_url = run
11255        .git_remote_url
11256        .as_deref()
11257        .zip(run.git_commit_long.as_deref())
11258        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
11259    let git_branch_url = run
11260        .git_remote_url
11261        .as_deref()
11262        .zip(run.git_branch.as_deref())
11263        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
11264    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
11265        format!(
11266            "{} / {}",
11267            run.environment.initiator_username, run.environment.initiator_hostname
11268        )
11269    });
11270
11271    // Convert absolute path to relative from run_dir (for file:// navigation).
11272    let make_rel = |p: Option<&Path>| -> Option<String> {
11273        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
11274            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
11275    };
11276
11277    let run_id = &run.tool.run_id;
11278
11279    // Submodule rows with relative paths into submodules/.
11280    let submodule_rows: Vec<SubmoduleRow> = run
11281        .submodule_summaries
11282        .iter()
11283        .map(|s| {
11284            let safe = sanitize_project_label(&s.name);
11285            let key = format!("sub_{safe}");
11286            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
11287            SubmoduleRow {
11288                name: s.name.clone(),
11289                relative_path: s.relative_path.clone(),
11290                files_analyzed: s.files_analyzed,
11291                code_lines: s.code_lines,
11292                comment_lines: s.comment_lines,
11293                blank_lines: s.blank_lines,
11294                total_physical_lines: s.total_physical_lines,
11295                html_url: if sub_path.exists() {
11296                    Some(format!("submodules/{key}.html"))
11297                } else {
11298                    None
11299                },
11300            }
11301        })
11302        .collect();
11303
11304    let lang_chart_json = {
11305        let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
11306        langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
11307        let entries: Vec<String> = langs
11308            .into_iter()
11309            .take(12)
11310            .map(|l| {
11311                let name = l.language.display_name()
11312                    .replace('\\', "\\\\")
11313                    .replace('"', "\\\"");
11314                format!(
11315                    r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
11316                    name, l.code_lines, l.comment_lines, l.blank_lines,
11317                    l.total_physical_lines, l.functions, l.classes,
11318                    l.variables, l.imports, l.files
11319                )
11320            })
11321            .collect();
11322        format!("[{}]", entries.join(","))
11323    };
11324
11325    let scan_config_rel =
11326        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
11327
11328    let template = ResultTemplate {
11329        version: env!("CARGO_PKG_VERSION"),
11330        report_title: run.effective_configuration.reporting.report_title.clone(),
11331        project_path: project_path.clone(),
11332        output_dir: display_path(run_dir),
11333        run_id: run_id.clone(),
11334        run_id_short: run_id
11335            .split('-')
11336            .next_back()
11337            .unwrap_or(run_id)
11338            .chars()
11339            .take(7)
11340            .collect(),
11341        files_analyzed,
11342        files_skipped,
11343        physical_lines,
11344        code_lines,
11345        comment_lines,
11346        blank_lines,
11347        mixed_lines,
11348        functions,
11349        classes,
11350        variables,
11351        imports,
11352        html_url: make_rel(html_path),
11353        pdf_url: make_rel(pdf_path),
11354        json_url: make_rel(json_path),
11355        html_download_url: make_rel(html_path),
11356        pdf_download_url: make_rel(pdf_path),
11357        json_download_url: make_rel(json_path),
11358        html_path: html_path.map(display_path),
11359        json_path: json_path.map(display_path),
11360        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
11361        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
11362        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
11363        prev_fa_str,
11364        prev_fs_str,
11365        prev_pl_str,
11366        prev_cl_str,
11367        prev_cml_str,
11368        prev_bl_str,
11369        delta_fa_str,
11370        delta_fa_class: delta_fa_class.to_string(),
11371        delta_fs_str,
11372        delta_fs_class: delta_fs_class.to_string(),
11373        delta_pl_str,
11374        delta_pl_class: delta_pl_class.to_string(),
11375        delta_cl_str,
11376        delta_cl_class: delta_cl_class.to_string(),
11377        delta_cml_str,
11378        delta_cml_class: delta_cml_class.to_string(),
11379        delta_bl_str,
11380        delta_bl_class: delta_bl_class.to_string(),
11381        delta_lines_added,
11382        delta_lines_removed,
11383        delta_lines_net_str,
11384        delta_lines_net_class,
11385        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
11386        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
11387        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
11388        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
11389        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
11390            d.file_deltas
11391                .iter()
11392                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
11393                .map(|f| {
11394                    #[allow(clippy::cast_sign_loss)]
11395                    let n = f.current_code as u64;
11396                    n
11397                })
11398                .sum()
11399        }),
11400        git_branch: run.git_branch.clone(),
11401        git_branch_url,
11402        git_commit: run.git_commit_short.clone(),
11403        git_commit_long: run.git_commit_long.clone(),
11404        git_author: run.git_commit_author.clone(),
11405        git_commit_url,
11406        scan_performed_by,
11407        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
11408        os_display: format!(
11409            "{} / {}",
11410            run.environment.operating_system, run.environment.architecture
11411        ),
11412        test_count: run.summary_totals.test_count,
11413        current_scan_number: prev_scan_count + 1,
11414        prev_scan_count,
11415        submodule_rows,
11416        pdf_generating: false,
11417        scan_config_url: scan_config_rel,
11418        lang_chart_json,
11419        scatter_chart_json: String::new(),
11420        semantic_chart_json: String::new(),
11421        submodule_chart_json: String::new(),
11422        has_submodule_data: !run.submodule_summaries.is_empty(),
11423        has_semantic_data: run
11424            .totals_by_language
11425            .iter()
11426            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
11427        csp_nonce: String::new(),
11428        confluence_configured: false,
11429        server_mode: false,
11430        report_header_footer: run
11431            .effective_configuration
11432            .reporting
11433            .report_header_footer
11434            .clone(),
11435        is_offline: true,
11436    };
11437
11438    if let Ok(html) = template.render() {
11439        let index_path = run_dir.join("index.html");
11440        if let Err(e) = fs::write(&index_path, html) {
11441            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
11442        }
11443    }
11444}
11445
11446/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
11447/// then root (old flat layout), for backwards compatibility.
11448fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
11449    // New layout: json/scan-config_*.json
11450    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
11451        return Some(found);
11452    }
11453    // Old flat layout: scan-config.json or scan-config_*.json at root
11454    find_scan_config_in_dir_flat(dir)
11455}
11456
11457fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
11458    let exact = dir.join("scan-config.json");
11459    if exact.exists() {
11460        return Some(exact);
11461    }
11462    fs::read_dir(dir).ok().and_then(|entries| {
11463        entries
11464            .filter_map(std::result::Result::ok)
11465            .find(|e| {
11466                let name = e.file_name();
11467                let name = name.to_string_lossy();
11468                name.starts_with("scan-config") && name.ends_with(".json")
11469            })
11470            .map(|e| e.path())
11471    })
11472}
11473
11474// ── Config export / import ────────────────────────────────────────────────────
11475
11476async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
11477    let toml_str = match toml::to_string_pretty(&state.base_config) {
11478        Ok(s) => s,
11479        Err(e) => {
11480            return (
11481                StatusCode::INTERNAL_SERVER_ERROR,
11482                format!("serialization error: {e}"),
11483            )
11484                .into_response();
11485        }
11486    };
11487    (
11488        [
11489            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
11490            (
11491                header::CONTENT_DISPOSITION,
11492                "attachment; filename=\".oxide-sloc.toml\"",
11493            ),
11494        ],
11495        toml_str,
11496    )
11497        .into_response()
11498}
11499
11500#[derive(Serialize)]
11501struct OkResponse {
11502    ok: bool,
11503}
11504
11505#[derive(Serialize)]
11506struct SaveProfileResponse {
11507    ok: bool,
11508    id: String,
11509}
11510
11511#[derive(Serialize)]
11512struct ProfileListResponse {
11513    profiles: Vec<ScanProfile>,
11514}
11515
11516#[derive(Serialize)]
11517struct ImportConfigResponse {
11518    ok: bool,
11519    config: sloc_config::AppConfig,
11520}
11521
11522#[derive(Deserialize)]
11523struct ImportConfigBody {
11524    toml: String,
11525}
11526
11527async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
11528    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
11529        Ok(config) => {
11530            if let Err(e) = config.validate() {
11531                return error::unprocessable_entity(&e.to_string());
11532            }
11533            Json(ImportConfigResponse { ok: true, config }).into_response()
11534        }
11535        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
11536    }
11537}
11538
11539// ── Scan profiles API ─────────────────────────────────────────────────────────
11540
11541async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
11542    let store = state.scan_profiles.lock().await;
11543    Json(ProfileListResponse {
11544        profiles: store.profiles.clone(),
11545    })
11546}
11547
11548#[derive(Deserialize)]
11549struct SaveScanProfileBody {
11550    name: String,
11551    params: serde_json::Value,
11552}
11553
11554async fn api_save_scan_profile(
11555    State(state): State<AppState>,
11556    Json(body): Json<SaveScanProfileBody>,
11557) -> impl IntoResponse {
11558    if body.name.trim().is_empty() {
11559        return error::bad_request("name must not be empty");
11560    }
11561
11562    let id = uuid::Uuid::new_v4().to_string();
11563    let profile = ScanProfile {
11564        id: id.clone(),
11565        name: body.name.trim().to_string(),
11566        created_at: chrono::Utc::now().to_rfc3339(),
11567        params: body.params,
11568    };
11569
11570    let mut store = state.scan_profiles.lock().await;
11571    store.profiles.push(profile);
11572    if let Err(e) = store.save(&state.scan_profiles_path) {
11573        tracing::warn!("failed to persist scan profiles: {e}");
11574    }
11575    drop(store);
11576
11577    (
11578        StatusCode::CREATED,
11579        Json(SaveProfileResponse { ok: true, id }),
11580    )
11581        .into_response()
11582}
11583
11584async fn api_delete_scan_profile(
11585    State(state): State<AppState>,
11586    AxumPath(id): AxumPath<String>,
11587) -> impl IntoResponse {
11588    let mut store = state.scan_profiles.lock().await;
11589    let before = store.profiles.len();
11590    store.profiles.retain(|p| p.id != id);
11591    if store.profiles.len() == before {
11592        drop(store);
11593        return error::not_found("profile not found");
11594    }
11595    if let Err(e) = store.save(&state.scan_profiles_path) {
11596        tracing::warn!("failed to persist scan profiles: {e}");
11597    }
11598    drop(store);
11599    Json(OkResponse { ok: true }).into_response()
11600}
11601
11602fn resolve_output_root(raw: Option<&str>) -> PathBuf {
11603    let value = raw.unwrap_or("out/web").trim();
11604    let path = if value.is_empty() {
11605        PathBuf::from("out/web")
11606    } else {
11607        PathBuf::from(value)
11608    };
11609
11610    if path.is_absolute() {
11611        path
11612    } else {
11613        workspace_root().join(path)
11614    }
11615}
11616
11617/// Derive the directory that holds remote-repo clones from the output root.
11618fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
11619    std::env::var("SLOC_GIT_CLONES_DIR")
11620        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
11621}
11622
11623/// Build a deterministic filesystem path for a cloned remote repository.
11624/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
11625pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
11626    let safe: String = repo_url
11627        .chars()
11628        .map(|c| {
11629            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
11630                c
11631            } else {
11632                '_'
11633            }
11634        })
11635        .take(80)
11636        .collect();
11637    clones_dir.join(safe)
11638}
11639
11640/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
11641/// Runs synchronously — call from `tokio::task::spawn_blocking`.
11642pub(crate) fn scan_path_to_artifacts(
11643    scan_path: &Path,
11644    base_config: &AppConfig,
11645    label: &str,
11646) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
11647    let mut config = base_config.clone();
11648    config.discovery.root_paths = vec![scan_path.to_path_buf()];
11649    label.clone_into(&mut config.reporting.report_title);
11650    let run = analyze(&config, "git", None, None)?;
11651    let html = render_html(&run)?;
11652    let run_id = run.tool.run_id.clone();
11653    let project_label = sanitize_project_label(label);
11654    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
11655    let file_stem = {
11656        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
11657        if commit.is_empty() {
11658            project_label
11659        } else {
11660            format!("{project_label}_{commit}")
11661        }
11662    };
11663    let (artifacts, _pending_pdf) = persist_run_artifacts(
11664        &run,
11665        &html,
11666        &output_dir,
11667        label,
11668        &file_stem,
11669        RunResultContext::default(),
11670    )?;
11671    Ok((run_id, artifacts, run))
11672}
11673
11674/// Re-spawn background poll tasks for any polling schedules saved to disk.
11675async fn restart_poll_schedules(state: &AppState) {
11676    let store = state.schedules.lock().await;
11677    let poll_schedules: Vec<_> = store
11678        .schedules
11679        .iter()
11680        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
11681        .cloned()
11682        .collect();
11683    drop(store);
11684    for schedule in poll_schedules {
11685        let interval = schedule.interval_secs.unwrap_or(300);
11686        let st = state.clone();
11687        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
11688    }
11689}
11690
11691fn split_patterns(raw: Option<&str>) -> Vec<String> {
11692    raw.unwrap_or("")
11693        .lines()
11694        .flat_map(|line| line.split(','))
11695        .map(str::trim)
11696        .filter(|part| !part.is_empty())
11697        .map(ToOwned::to_owned)
11698        .collect()
11699}
11700
11701#[must_use]
11702pub fn build_sub_run(
11703    parent: &AnalysisRun,
11704    sub: &sloc_core::SubmoduleSummary,
11705    parent_path: &str,
11706) -> AnalysisRun {
11707    let sub_files: Vec<_> = parent
11708        .per_file_records
11709        .iter()
11710        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
11711        .cloned()
11712        .collect();
11713    let mut config = parent.effective_configuration.clone();
11714    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
11715
11716    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
11717    let mut functions = 0u64;
11718    let mut classes = 0u64;
11719    let mut variables = 0u64;
11720    let mut imports = 0u64;
11721    let mut test_count = 0u64;
11722    let mut test_assertion_count = 0u64;
11723    let mut test_suite_count = 0u64;
11724    let mut mixed_lines_separate = 0u64;
11725    let mut coverage_lines_found = 0u64;
11726    let mut coverage_lines_hit = 0u64;
11727    let mut coverage_functions_found = 0u64;
11728    let mut coverage_functions_hit = 0u64;
11729    let mut coverage_branches_found = 0u64;
11730    let mut coverage_branches_hit = 0u64;
11731    for r in &sub_files {
11732        functions += r.raw_line_categories.functions;
11733        classes += r.raw_line_categories.classes;
11734        variables += r.raw_line_categories.variables;
11735        imports += r.raw_line_categories.imports;
11736        test_count += r.raw_line_categories.test_count;
11737        test_assertion_count += r.raw_line_categories.test_assertion_count;
11738        test_suite_count += r.raw_line_categories.test_suite_count;
11739        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
11740        if let Some(cov) = &r.coverage {
11741            coverage_lines_found += u64::from(cov.lines_found);
11742            coverage_lines_hit += u64::from(cov.lines_hit);
11743            coverage_functions_found += u64::from(cov.functions_found);
11744            coverage_functions_hit += u64::from(cov.functions_hit);
11745            coverage_branches_found += u64::from(cov.branches_found);
11746            coverage_branches_hit += u64::from(cov.branches_hit);
11747        }
11748    }
11749
11750    AnalysisRun {
11751        tool: parent.tool.clone(),
11752        environment: parent.environment.clone(),
11753        effective_configuration: config,
11754        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
11755        summary_totals: SummaryTotals {
11756            files_considered: sub.files_analyzed,
11757            files_analyzed: sub.files_analyzed,
11758            files_skipped: 0,
11759            total_physical_lines: sub.total_physical_lines,
11760            code_lines: sub.code_lines,
11761            comment_lines: sub.comment_lines,
11762            blank_lines: sub.blank_lines,
11763            mixed_lines_separate,
11764            functions,
11765            classes,
11766            variables,
11767            imports,
11768            test_count,
11769            test_assertion_count,
11770            test_suite_count,
11771            coverage_lines_found,
11772            coverage_lines_hit,
11773            coverage_functions_found,
11774            coverage_functions_hit,
11775            coverage_branches_found,
11776            coverage_branches_hit,
11777        },
11778        totals_by_language: sub.language_summaries.clone(),
11779        per_file_records: sub_files,
11780        skipped_file_records: vec![],
11781        warnings: vec![],
11782        submodule_summaries: vec![],
11783        git_commit_short: sub.git_commit_short.clone(),
11784        git_commit_long: sub.git_commit_long.clone(),
11785        git_branch: sub.git_branch.clone(),
11786        git_commit_author: sub.git_commit_author.clone(),
11787        git_commit_date: sub.git_commit_date.clone(),
11788        git_tags: None,
11789        git_nearest_tag: None,
11790        git_remote_url: sub.git_remote_url.clone(),
11791        style_summary: None,
11792    }
11793}
11794
11795#[must_use]
11796pub fn sanitize_project_label(raw: &str) -> String {
11797    let candidate = Path::new(raw)
11798        .file_name()
11799        .and_then(|name| name.to_str())
11800        .unwrap_or("project");
11801
11802    let mut value = String::with_capacity(candidate.len());
11803    for ch in candidate.chars() {
11804        if ch.is_ascii_alphanumeric() {
11805            value.push(ch.to_ascii_lowercase());
11806        } else {
11807            value.push('-');
11808        }
11809    }
11810
11811    let compact = value.trim_matches('-').to_string();
11812    if compact.is_empty() {
11813        "project".to_string()
11814    } else {
11815        compact
11816    }
11817}
11818
11819/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
11820/// comparisons with non-canonicalized stored paths work correctly.
11821fn strip_unc_prefix(path: PathBuf) -> PathBuf {
11822    let s = path.to_string_lossy();
11823    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11824        return PathBuf::from(format!(r"\\{rest}"));
11825    }
11826    if let Some(rest) = s.strip_prefix(r"\\?\") {
11827        return PathBuf::from(rest);
11828    }
11829    path
11830}
11831
11832/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
11833/// commit page URL for the most common hosting platforms.
11834fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
11835    let base = if let Some(rest) = remote.strip_prefix("git@") {
11836        let (host, path) = rest.split_once(':')?;
11837        format!("https://{}/{}", host, path.trim_end_matches(".git"))
11838    } else if remote.starts_with("https://") || remote.starts_with("http://") {
11839        remote
11840            .trim_end_matches('/')
11841            .trim_end_matches(".git")
11842            .to_owned()
11843    } else {
11844        return None;
11845    };
11846    let base = base.trim_end_matches('/');
11847    // GitLab uses /-/commit/; everything else uses /commit/
11848    if base.contains("gitlab.com") || base.contains("gitlab.") {
11849        Some(format!("{base}/-/commit/{sha}"))
11850    } else if base.contains("bitbucket.org") {
11851        Some(format!("{base}/commits/{sha}"))
11852    } else {
11853        Some(format!("{base}/commit/{sha}"))
11854    }
11855}
11856
11857/// Convert a git remote URL (https or git@) + branch name into a browser-openable
11858/// branch page URL for the most common hosting platforms.
11859fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
11860    let base = if let Some(rest) = remote.strip_prefix("git@") {
11861        let (host, path) = rest.split_once(':')?;
11862        format!("https://{}/{}", host, path.trim_end_matches(".git"))
11863    } else if remote.starts_with("https://") || remote.starts_with("http://") {
11864        remote
11865            .trim_end_matches('/')
11866            .trim_end_matches(".git")
11867            .to_owned()
11868    } else {
11869        return None;
11870    };
11871    let base = base.trim_end_matches('/');
11872    if base.contains("gitlab.com") || base.contains("gitlab.") {
11873        Some(format!("{base}/-/tree/{branch}"))
11874    } else {
11875        Some(format!("{base}/tree/{branch}"))
11876    }
11877}
11878
11879fn display_path(path: &Path) -> String {
11880    let s = path.to_string_lossy();
11881    // Strip Windows extended-length prefix for display only; the underlying
11882    // PathBuf remains unchanged so file operations are unaffected.
11883    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
11884    // \\?\C:\path           →  C:\path          (local drive)
11885    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11886        return format!(r"\\{rest}");
11887    }
11888    if let Some(rest) = s.strip_prefix(r"\\?\") {
11889        return rest.to_owned();
11890    }
11891    s.into_owned()
11892}
11893
11894fn sanitize_path_str(s: &str) -> String {
11895    // Forward-slash variants of the Windows extended-length prefix that appear
11896    // when paths stored as plain strings have been processed through some path
11897    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
11898    if let Some(rest) = s.strip_prefix("//?/UNC/") {
11899        return format!("//{rest}");
11900    }
11901    if let Some(rest) = s.strip_prefix("//?/") {
11902        return rest.to_owned();
11903    }
11904    display_path(Path::new(s))
11905}
11906
11907fn workspace_root() -> PathBuf {
11908    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
11909    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
11910        let p = PathBuf::from(root);
11911        if p.is_dir() {
11912            return p;
11913        }
11914    }
11915
11916    // Current working directory — works for `cargo run` from the project root
11917    // and for scripts/run.sh which cds there first.
11918    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
11919}
11920
11921/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
11922fn make_git_label(repo: &str, ref_name: &str) -> String {
11923    if repo.is_empty() || ref_name.is_empty() {
11924        return String::new();
11925    }
11926    let base = repo
11927        .trim_end_matches('/')
11928        .trim_end_matches(".git")
11929        .rsplit('/')
11930        .next()
11931        .unwrap_or("repo");
11932    let ref_safe: String = ref_name
11933        .chars()
11934        .map(|c| {
11935            if c.is_alphanumeric() || c == '-' || c == '.' {
11936                c
11937            } else {
11938                '_'
11939            }
11940        })
11941        .collect();
11942    format!("{base}_at_{ref_safe}_sloc")
11943}
11944
11945/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
11946fn desktop_dir() -> PathBuf {
11947    if let Ok(profile) = std::env::var("USERPROFILE") {
11948        let p = PathBuf::from(profile).join("Desktop");
11949        if p.exists() {
11950            return p;
11951        }
11952    }
11953    if let Ok(home) = std::env::var("HOME") {
11954        let p = PathBuf::from(home).join("Desktop");
11955        if p.exists() {
11956            return p;
11957        }
11958    }
11959    workspace_root().join("out").join("web")
11960}
11961
11962fn resolve_input_path(raw: &str) -> PathBuf {
11963    let trimmed = raw.trim();
11964    if trimmed.is_empty() {
11965        return workspace_root().join("samples").join("basic");
11966    }
11967
11968    let candidate = PathBuf::from(trimmed);
11969    let resolved = if candidate.is_absolute() {
11970        candidate
11971    } else {
11972        let rooted = workspace_root().join(&candidate);
11973        if rooted.exists() {
11974            rooted
11975        } else {
11976            workspace_root().join(candidate)
11977        }
11978    };
11979
11980    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
11981    // strip that prefix so stored paths and the displayed "Project path" are clean.
11982    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
11983    PathBuf::from(display_path(&canonical))
11984}
11985
11986fn dir_size_bytes(path: &Path) -> u64 {
11987    let mut total = 0u64;
11988    if let Ok(rd) = fs::read_dir(path) {
11989        for entry in rd.filter_map(Result::ok) {
11990            let p = entry.path();
11991            if p.is_file() {
11992                if let Ok(meta) = p.metadata() {
11993                    total += meta.len();
11994                }
11995            } else if p.is_dir() {
11996                total += dir_size_bytes(&p);
11997            }
11998        }
11999    }
12000    total
12001}
12002
12003#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
12004fn format_dir_size(bytes: u64) -> String {
12005    if bytes >= 1_073_741_824 {
12006        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
12007    } else if bytes >= 1_048_576 {
12008        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
12009    } else if bytes >= 1_024 {
12010        format!("{:.0} KB", bytes as f64 / 1_024.0)
12011    } else {
12012        format!("{bytes} B")
12013    }
12014}
12015
12016fn render_submodule_chips(
12017    root: &Path,
12018    submodules: &[(String, std::path::PathBuf)],
12019    out: &mut String,
12020) {
12021    use std::fmt::Write as _;
12022    let count = submodules.len();
12023    out.push_str(r#"<div class="submodule-preview-strip">"#);
12024    write!(
12025        out,
12026        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>"#,
12027        if count == 1 { "" } else { "s" }
12028    )
12029    .ok();
12030    out.push_str(r#"<div class="submodule-preview-chips">"#);
12031    for (sub_name, sub_rel_path) in submodules {
12032        let sub_abs = root.join(sub_rel_path);
12033        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
12034        let mut sub_stats = PreviewStats::default();
12035        let mut sub_rows: Vec<PreviewRow> = Vec::new();
12036        let mut sub_langs: Vec<&'static str> = Vec::new();
12037        let mut sub_budget = PreviewBudget {
12038            shown: 0,
12039            max_entries: 2000,
12040            max_depth: 9,
12041        };
12042        let mut sub_next_id = 1usize;
12043        let _ = collect_preview_rows(
12044            &sub_abs,
12045            &sub_abs,
12046            0,
12047            None,
12048            &mut sub_next_id,
12049            &mut sub_budget,
12050            &mut sub_stats,
12051            &mut sub_rows,
12052            &mut sub_langs,
12053            &[],
12054            &[],
12055        );
12056        let stats_json = format!(
12057            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
12058            sub_stats.directories,
12059            sub_stats.files,
12060            sub_stats.supported,
12061            sub_stats.skipped,
12062            sub_stats.unsupported
12063        );
12064        write!(
12065            out,
12066            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>"#,
12067            escape_html(sub_name),
12068            escape_html(&sub_rel_path.to_string_lossy()),
12069            escape_html(&sub_size),
12070            escape_html(&stats_json),
12071            escape_html(sub_name),
12072            escape_html(&sub_size),
12073        )
12074        .ok();
12075    }
12076    out.push_str(
12077        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
12078    );
12079    out.push_str(r"</div>");
12080}
12081
12082fn render_language_pills_row(languages: &[&str], out: &mut String) {
12083    use std::fmt::Write as _;
12084    if languages.is_empty() {
12085        out.push_str(
12086            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
12087        );
12088        return;
12089    }
12090    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
12091    for language in languages {
12092        if let Some(icon) = language_icon_file(language) {
12093            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();
12094        } else if let Some(svg) = language_inline_svg(language) {
12095            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();
12096        } else {
12097            write!(
12098                out,
12099                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
12100                escape_html(&language.to_ascii_lowercase()),
12101                escape_html(language)
12102            )
12103            .ok();
12104        }
12105    }
12106}
12107
12108#[allow(clippy::too_many_lines)]
12109fn build_preview_html(
12110    root: &Path,
12111    include_patterns: &[String],
12112    exclude_patterns: &[String],
12113) -> Result<String> {
12114    if !root.exists() {
12115        return Ok(format!(
12116            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
12117            escape_html(&display_path(root))
12118        ));
12119    }
12120
12121    let _selected = display_path(root);
12122    let mut stats = PreviewStats::default();
12123    let mut rows = Vec::new();
12124    let mut languages = Vec::new();
12125    let mut budget = PreviewBudget {
12126        shown: 0,
12127        max_entries: 600,
12128        max_depth: 9,
12129    };
12130    let mut next_row_id = 1usize;
12131
12132    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
12133        || root.to_string_lossy().into_owned(),
12134        std::string::ToString::to_string,
12135    );
12136    let root_modified = root
12137        .metadata()
12138        .ok()
12139        .and_then(|meta| meta.modified().ok())
12140        .map_or_else(|| "-".to_string(), format_system_time);
12141
12142    rows.push(PreviewRow {
12143        row_id: 0,
12144        parent_row_id: None,
12145        depth: 0,
12146        name: format!("{root_name}/"),
12147        kind: PreviewKind::Dir,
12148        is_dir: true,
12149        language: None,
12150        modified: root_modified,
12151        type_label: "Directory".to_string(),
12152    });
12153    collect_preview_rows(
12154        root,
12155        root,
12156        0,
12157        Some(0),
12158        &mut next_row_id,
12159        &mut budget,
12160        &mut stats,
12161        &mut rows,
12162        &mut languages,
12163        include_patterns,
12164        exclude_patterns,
12165    )?;
12166
12167    let root_size = format_dir_size(dir_size_bytes(root));
12168
12169    let mut out = String::new();
12170    write!(
12171        out,
12172        r#"<div class="explorer-wrap" data-project-size="{}">"#,
12173        escape_html(&root_size)
12174    )
12175    .ok();
12176    out.push_str(r#"<div class="explorer-toolbar compact">"#);
12177    out.push_str(r#"<div class="explorer-title-group">"#);
12178    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
12179    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
12180    out.push_str(r"</div></div>");
12181
12182    out.push_str(r#"<div class="scope-stats">"#);
12183    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();
12184    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();
12185    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();
12186    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();
12187    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();
12188    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>"#);
12189    out.push_str(r"</div>");
12190
12191    let submodules = sloc_core::detect_submodules(root);
12192    if !submodules.is_empty() {
12193        render_submodule_chips(root, &submodules, &mut out);
12194    }
12195
12196    out.push_str(r#"<div class="scope-info-row">"#);
12197    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
12198    render_language_pills_row(&languages, &mut out);
12199    out.push_str(r"</div></div>");
12200    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>"#);
12201    out.push_str(r"</div>");
12202
12203    out.push_str(r#"<div class="file-explorer-shell">"#);
12204    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>"#);
12205    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>"#);
12206    out.push_str(r#"<div class="file-explorer-tree">"#);
12207    for row in rows {
12208        let status_label = row.kind.label();
12209        let lang_attr = row.language.unwrap_or("");
12210        let toggle_html = if row.is_dir {
12211            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
12212                .to_string()
12213        } else {
12214            r#"<span class="tree-bullet">•</span>"#.to_string()
12215        };
12216        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();
12217    }
12218    if budget.shown >= budget.max_entries {
12219        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>"#);
12220    }
12221    out.push_str(r"</div></div></div>");
12222
12223    Ok(out)
12224}
12225
12226#[derive(Default)]
12227struct PreviewStats {
12228    directories: usize,
12229    files: usize,
12230    supported: usize,
12231    skipped: usize,
12232    unsupported: usize,
12233}
12234
12235struct PreviewRow {
12236    row_id: usize,
12237    parent_row_id: Option<usize>,
12238    depth: usize,
12239    name: String,
12240    kind: PreviewKind,
12241    is_dir: bool,
12242    language: Option<&'static str>,
12243    modified: String,
12244    type_label: String,
12245}
12246
12247#[derive(Copy, Clone)]
12248enum PreviewKind {
12249    Dir,
12250    Supported,
12251    Skipped,
12252    Unsupported,
12253}
12254
12255impl PreviewKind {
12256    const fn filter_key(self) -> &'static str {
12257        match self {
12258            Self::Dir => "dir",
12259            Self::Supported => "supported",
12260            Self::Skipped => "skipped",
12261            Self::Unsupported => "unsupported",
12262        }
12263    }
12264
12265    const fn label(self) -> &'static str {
12266        match self {
12267            Self::Dir => "dir",
12268            Self::Supported => "supported",
12269            Self::Skipped => "skipped by policy",
12270            Self::Unsupported => "unsupported",
12271        }
12272    }
12273
12274    const fn badge_class(self) -> &'static str {
12275        match self {
12276            Self::Dir => "badge badge-dir",
12277            Self::Supported => "badge badge-scan",
12278            Self::Skipped => "badge badge-skip",
12279            Self::Unsupported => "badge badge-unsupported",
12280        }
12281    }
12282
12283    const fn node_class(self) -> &'static str {
12284        match self {
12285            Self::Dir => "tree-node-dir",
12286            Self::Supported => "tree-node-supported",
12287            Self::Skipped => "tree-node-skipped",
12288            Self::Unsupported => "tree-node-unsupported",
12289        }
12290    }
12291}
12292
12293struct PreviewBudget {
12294    shown: usize,
12295    max_entries: usize,
12296    max_depth: usize,
12297}
12298
12299/// Handle a single directory entry inside `collect_preview_rows`.
12300/// Returns `true` when the entry was handled (caller should `continue`).
12301#[allow(clippy::too_many_arguments)]
12302fn handle_preview_dir_entry(
12303    root: &Path,
12304    path: &Path,
12305    name: &str,
12306    modified: String,
12307    depth: usize,
12308    parent_row_id: Option<usize>,
12309    row_id: usize,
12310    next_row_id: &mut usize,
12311    budget: &mut PreviewBudget,
12312    stats: &mut PreviewStats,
12313    rows: &mut Vec<PreviewRow>,
12314    languages: &mut Vec<&'static str>,
12315    include_patterns: &[String],
12316    exclude_patterns: &[String],
12317) -> Result<()> {
12318    let relative = preview_relative_path(root, path);
12319    if should_skip_preview_directory(&relative, exclude_patterns) {
12320        return Ok(());
12321    }
12322    stats.directories += 1;
12323    rows.push(PreviewRow {
12324        row_id,
12325        parent_row_id,
12326        depth: depth + 1,
12327        name: format!("{name}/"),
12328        kind: PreviewKind::Dir,
12329        is_dir: true,
12330        language: None,
12331        modified,
12332        type_label: "Directory".to_string(),
12333    });
12334    budget.shown += 1;
12335    if !matches!(name, ".git" | "node_modules" | "target") {
12336        collect_preview_rows(
12337            root,
12338            path,
12339            depth + 1,
12340            Some(row_id),
12341            next_row_id,
12342            budget,
12343            stats,
12344            rows,
12345            languages,
12346            include_patterns,
12347            exclude_patterns,
12348        )?;
12349    }
12350    Ok(())
12351}
12352
12353/// Handle a single file entry inside `collect_preview_rows`.
12354#[allow(clippy::too_many_arguments)]
12355fn handle_preview_file_entry(
12356    root: &Path,
12357    path: &Path,
12358    name: &str,
12359    modified: String,
12360    depth: usize,
12361    parent_row_id: Option<usize>,
12362    row_id: usize,
12363    budget: &mut PreviewBudget,
12364    stats: &mut PreviewStats,
12365    rows: &mut Vec<PreviewRow>,
12366    languages: &mut Vec<&'static str>,
12367    include_patterns: &[String],
12368    exclude_patterns: &[String],
12369) {
12370    let relative = preview_relative_path(root, path);
12371    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
12372        return;
12373    }
12374    stats.files += 1;
12375    let kind = classify_preview_file(name);
12376    match kind {
12377        PreviewKind::Supported => stats.supported += 1,
12378        PreviewKind::Skipped => stats.skipped += 1,
12379        PreviewKind::Unsupported => stats.unsupported += 1,
12380        PreviewKind::Dir => {}
12381    }
12382    let language = detect_language_name(name);
12383    if let Some(lang) = language {
12384        if !languages.contains(&lang) {
12385            languages.push(lang);
12386        }
12387    }
12388    rows.push(PreviewRow {
12389        row_id,
12390        parent_row_id,
12391        depth: depth + 1,
12392        name: name.to_owned(),
12393        kind,
12394        is_dir: false,
12395        language,
12396        modified,
12397        type_label: preview_type_label(name, language, kind),
12398    });
12399    budget.shown += 1;
12400}
12401
12402#[allow(clippy::too_many_arguments)]
12403#[allow(clippy::too_many_lines)]
12404fn collect_preview_rows(
12405    root: &Path,
12406    dir: &Path,
12407    depth: usize,
12408    parent_row_id: Option<usize>,
12409    next_row_id: &mut usize,
12410    budget: &mut PreviewBudget,
12411    stats: &mut PreviewStats,
12412    rows: &mut Vec<PreviewRow>,
12413    languages: &mut Vec<&'static str>,
12414    include_patterns: &[String],
12415    exclude_patterns: &[String],
12416) -> Result<()> {
12417    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
12418        return Ok(());
12419    }
12420
12421    let mut entries = fs::read_dir(dir)
12422        .with_context(|| format!("failed to read directory {}", dir.display()))?
12423        .filter_map(std::result::Result::ok)
12424        .collect::<Vec<_>>();
12425    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
12426
12427    for entry in entries {
12428        if budget.shown >= budget.max_entries {
12429            break;
12430        }
12431
12432        let path = entry.path();
12433        let name = entry.file_name().to_string_lossy().into_owned();
12434        let Ok(metadata) = entry.metadata() else {
12435            continue;
12436        };
12437        let row_id = *next_row_id;
12438        *next_row_id += 1;
12439        let modified = metadata
12440            .modified()
12441            .ok()
12442            .map_or_else(|| "-".to_string(), format_system_time);
12443
12444        if metadata.is_dir() {
12445            handle_preview_dir_entry(
12446                root,
12447                &path,
12448                &name,
12449                modified,
12450                depth,
12451                parent_row_id,
12452                row_id,
12453                next_row_id,
12454                budget,
12455                stats,
12456                rows,
12457                languages,
12458                include_patterns,
12459                exclude_patterns,
12460            )?;
12461            continue;
12462        }
12463
12464        if metadata.is_file() {
12465            handle_preview_file_entry(
12466                root,
12467                &path,
12468                &name,
12469                modified,
12470                depth,
12471                parent_row_id,
12472                row_id,
12473                budget,
12474                stats,
12475                rows,
12476                languages,
12477                include_patterns,
12478                exclude_patterns,
12479            );
12480        }
12481    }
12482
12483    Ok(())
12484}
12485
12486fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
12487    if let Some(language) = language {
12488        return format!("{language} source");
12489    }
12490    let lower = name.to_ascii_lowercase();
12491    let ext = Path::new(&lower)
12492        .extension()
12493        .and_then(|e| e.to_str())
12494        .unwrap_or("");
12495    match kind {
12496        PreviewKind::Skipped => {
12497            if lower.ends_with(".min.js") {
12498                "Minified asset".to_string()
12499            } else if [
12500                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
12501            ]
12502            .contains(&ext)
12503            {
12504                "Binary or archive".to_string()
12505            } else {
12506                "Skipped file".to_string()
12507            }
12508        }
12509        PreviewKind::Unsupported => {
12510            if ext.is_empty() {
12511                "Unsupported file".to_string()
12512            } else {
12513                format!("{} file", ext.to_ascii_uppercase())
12514            }
12515        }
12516        PreviewKind::Supported => "Supported source".to_string(),
12517        PreviewKind::Dir => "Directory".to_string(),
12518    }
12519}
12520
12521fn format_system_time(time: SystemTime) -> String {
12522    #[allow(clippy::cast_possible_wrap)]
12523    let secs = match time.duration_since(UNIX_EPOCH) {
12524        Ok(duration) => duration.as_secs() as i64,
12525        Err(_) => return "-".to_string(),
12526    };
12527    let days = secs.div_euclid(86_400);
12528    let secs_of_day = secs.rem_euclid(86_400);
12529    let (year, month, day) = civil_from_days(days);
12530    let hour = secs_of_day / 3_600;
12531    let minute = (secs_of_day % 3_600) / 60;
12532    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
12533}
12534
12535#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
12536fn civil_from_days(days: i64) -> (i32, u32, u32) {
12537    let z = days + 719_468;
12538    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
12539    let doe = z - era * 146_097;
12540    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
12541    let y = yoe + era * 400;
12542    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
12543    let mp = (5 * doy + 2) / 153;
12544    let d = doy - (153 * mp + 2) / 5 + 1;
12545    let m = mp + if mp < 10 { 3 } else { -9 };
12546    let year = y + i64::from(m <= 2);
12547    (year as i32, m as u32, d as u32)
12548}
12549
12550// The input is already lowercased via `to_ascii_lowercase()` before calling
12551// `ends_with`, so the comparisons are inherently case-insensitive.
12552#[allow(clippy::case_sensitive_file_extension_comparisons)]
12553fn detect_language_name(name: &str) -> Option<&'static str> {
12554    let lower = name.to_ascii_lowercase();
12555    if lower.ends_with(".c") || lower.ends_with(".h") {
12556        Some("C")
12557    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
12558        .iter()
12559        .any(|s| lower.ends_with(s))
12560    {
12561        Some("C++")
12562    } else if lower.ends_with(".cs") {
12563        Some("C#")
12564    } else if lower.ends_with(".py") {
12565        Some("Python")
12566    } else if lower.ends_with(".sh") {
12567        Some("Shell")
12568    } else if [".ps1", ".psm1", ".psd1"]
12569        .iter()
12570        .any(|s| lower.ends_with(s))
12571    {
12572        Some("PowerShell")
12573    } else {
12574        None
12575    }
12576}
12577
12578fn language_icon_file(language: &str) -> Option<&'static str> {
12579    match language {
12580        "C" => Some("c.png"),
12581        "C++" => Some("cpp.png"),
12582        "C#" => Some("c-sharp.png"),
12583        "Python" => Some("python.png"),
12584        "Shell" => Some("shell.png"),
12585        "PowerShell" => Some("powershell.png"),
12586        "JavaScript" => Some("java-script.png"),
12587        "HTML" => Some("html-5.png"),
12588        "Java" => Some("java.png"),
12589        "Visual Basic" => Some("visual-basic.png"),
12590        "Assembly" => Some("asm.png"),
12591        "Go" => Some("go.png"),
12592        "R" => Some("r.png"),
12593        "XML" => Some("xml.png"),
12594        "Groovy" => Some("groovy.png"),
12595        "Dockerfile" => Some("docker.png"),
12596        "Makefile" => Some("makefile.svg"),
12597        "Perl" => Some("perl.svg"),
12598        _ => None,
12599    }
12600}
12601
12602// Inline SVG badges for languages that have no PNG icon in images/icons/.
12603// Using inline SVG keeps the web UI fully self-contained — no extra files
12604// needed on disk, no 404s on air-gapped deployments.
12605// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
12606fn language_inline_svg(language: &str) -> Option<&'static str> {
12607    match language {
12608        "Rust" => Some(
12609            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>"##,
12610        ),
12611        "TypeScript" => Some(
12612            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>"##,
12613        ),
12614        _ => None,
12615    }
12616}
12617
12618// The input is already lowercased via `to_ascii_lowercase()` before the
12619// `ends_with` calls, so these comparisons are inherently case-insensitive.
12620#[allow(clippy::case_sensitive_file_extension_comparisons)]
12621fn classify_preview_file(name: &str) -> PreviewKind {
12622    let lower = name.to_ascii_lowercase();
12623
12624    let scannable = [
12625        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
12626        ".psm1", ".psd1",
12627    ]
12628    .iter()
12629    .any(|suffix| lower.ends_with(suffix));
12630
12631    if scannable {
12632        PreviewKind::Supported
12633    } else if lower.ends_with(".min.js")
12634        || lower.ends_with(".lock")
12635        || lower.ends_with(".png")
12636        || lower.ends_with(".jpg")
12637        || lower.ends_with(".jpeg")
12638        || lower.ends_with(".gif")
12639        || lower.ends_with(".zip")
12640        || lower.ends_with(".pdf")
12641        || lower.ends_with(".pyc")
12642        || lower.ends_with(".xz")
12643        || lower.ends_with(".tar")
12644        || lower.ends_with(".gz")
12645    {
12646        PreviewKind::Skipped
12647    } else {
12648        PreviewKind::Unsupported
12649    }
12650}
12651
12652fn preview_relative_path(root: &Path, path: &Path) -> String {
12653    path.strip_prefix(root)
12654        .ok()
12655        .unwrap_or(path)
12656        .to_string_lossy()
12657        .replace('\\', "/")
12658        .trim_matches('/')
12659        .to_string()
12660}
12661
12662fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
12663    if relative.is_empty() {
12664        return false;
12665    }
12666
12667    exclude_patterns.iter().any(|pattern| {
12668        wildcard_match(pattern, relative)
12669            || wildcard_match(pattern, &format!("{relative}/"))
12670            || wildcard_match(pattern, &format!("{relative}/placeholder"))
12671    })
12672}
12673
12674fn should_include_preview_file(
12675    relative: &str,
12676    include_patterns: &[String],
12677    exclude_patterns: &[String],
12678) -> bool {
12679    if relative.is_empty() {
12680        return true;
12681    }
12682
12683    let included = include_patterns.is_empty()
12684        || include_patterns
12685            .iter()
12686            .any(|pattern| wildcard_match(pattern, relative));
12687    let excluded = exclude_patterns
12688        .iter()
12689        .any(|pattern| wildcard_match(pattern, relative));
12690
12691    included && !excluded
12692}
12693
12694fn wildcard_match(pattern: &str, candidate: &str) -> bool {
12695    let pattern = pattern.trim().replace('\\', "/");
12696    let candidate = candidate.trim().replace('\\', "/");
12697    let p = pattern.as_bytes();
12698    let c = candidate.as_bytes();
12699    let mut pi = 0usize;
12700    let mut ci = 0usize;
12701    let mut star: Option<usize> = None;
12702    let mut star_match = 0usize;
12703
12704    while ci < c.len() {
12705        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
12706            pi += 1;
12707            ci += 1;
12708        } else if pi < p.len() && p[pi] == b'*' {
12709            while pi < p.len() && p[pi] == b'*' {
12710                pi += 1;
12711            }
12712            star = Some(pi);
12713            star_match = ci;
12714        } else if let Some(star_pi) = star {
12715            star_match += 1;
12716            ci = star_match;
12717            pi = star_pi;
12718        } else {
12719            return false;
12720        }
12721    }
12722
12723    while pi < p.len() && p[pi] == b'*' {
12724        pi += 1;
12725    }
12726
12727    pi == p.len()
12728}
12729
12730fn escape_html(value: &str) -> String {
12731    value
12732        .replace('&', "&amp;")
12733        .replace('<', "&lt;")
12734        .replace('>', "&gt;")
12735        .replace('"', "&quot;")
12736        .replace('\'', "&#39;")
12737}
12738
12739#[derive(Clone)]
12740struct SubmoduleRow {
12741    name: String,
12742    relative_path: String,
12743    files_analyzed: u64,
12744    code_lines: u64,
12745    comment_lines: u64,
12746    blank_lines: u64,
12747    total_physical_lines: u64,
12748    html_url: Option<String>,
12749}
12750
12751#[derive(Template)]
12752#[template(
12753    source = r##"
12754<!doctype html>
12755<html lang="en">
12756<head>
12757  <meta charset="utf-8">
12758  <title>OxideSLOC | tmp-sloc</title>
12759  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12760  <style nonce="{{ csp_nonce }}">
12761    :root {
12762      --bg: #efe9e2;
12763      --surface: #fcfaf7;
12764      --surface-2: #f7f0e8;
12765      --surface-3: #efe3d5;
12766      --line: #dfcfbf;
12767      --line-strong: #cfb29c;
12768      --text: #2f241c;
12769      --muted: #6f6257;
12770      --muted-2: #917f71;
12771      --nav: #b85d33;
12772      --nav-2: #7a371b;
12773      --accent: #2563eb;
12774      --accent-2: #1d4ed8;
12775      --oxide: #b85d33;
12776      --oxide-2: #8f4220;
12777      --success-bg: #eaf9ee;
12778      --success-text: #1c8746;
12779      --warn-bg: #fff2d8;
12780      --warn-text: #926000;
12781      --danger-bg: #fdeaea;
12782      --danger-text: #b33b3b;
12783      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
12784      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
12785      --radius: 14px;
12786    }
12787
12788    body.dark-theme {
12789      --bg: #1b1511;
12790      --surface: #261c17;
12791      --surface-2: #2d221d;
12792      --surface-3: #372922;
12793      --line: #524238;
12794      --line-strong: #6c5649;
12795      --text: #f5ece6;
12796      --muted: #c7b7aa;
12797      --muted-2: #aa9485;
12798      --nav: #b85d33;
12799      --nav-2: #7a371b;
12800      --accent: #6f9bff;
12801      --accent-2: #4a78ee;
12802      --oxide: #d37a4c;
12803      --oxide-2: #b35428;
12804      --success-bg: #163927;
12805      --success-text: #8fe2a8;
12806      --warn-bg: #3c2d11;
12807      --warn-text: #f3cb75;
12808      --danger-bg: #3d1f1f;
12809      --danger-text: #ff9f9f;
12810      --shadow: 0 14px 28px rgba(0,0,0,0.28);
12811      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
12812    }
12813
12814    * { box-sizing: border-box; }
12815    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); }
12816    html { overflow-y: scroll; }
12817    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
12818    .top-nav, .page, .loading { position: relative; z-index: 2; }
12819    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
12820    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
12821    .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); }
12822    .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; }
12823    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
12824    .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)); }
12825    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
12826    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
12827    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
12828    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
12829    .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; }
12830    .nav-project-pill.visible { display:inline-flex; }
12831    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
12832    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
12833    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
12834    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12835    @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; } }
12836    .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; }
12837    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
12838    .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; }
12839    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
12840    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
12841    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
12842    .theme-toggle .icon-sun { display:none; }
12843    body.dark-theme .theme-toggle .icon-sun { display:block; }
12844    body.dark-theme .theme-toggle .icon-moon { display:none; }
12845    .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;}
12846    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12847    .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);}
12848    .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;}
12849    .settings-close:hover{color:var(--text);background:var(--surface-2);}
12850    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12851    .settings-modal-body{padding:14px 16px 16px;}
12852    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12853    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12854    .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;}
12855    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12856    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12857    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12858    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12859    .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;}
12860    .tz-select:focus{border-color:var(--oxide);}
12861    .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; }
12862    .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;}
12863    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
12864    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
12865    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
12866    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
12867    .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; }
12868    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
12869    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
12870    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
12871    .wb-stats-header { padding: 10px 24px 0; }
12872    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
12873    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
12874    .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; }
12875    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12876    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12877    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12878    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
12879    .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; }
12880    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
12881    .ws-stat-analyzers { position: relative; }
12882    .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; }
12883    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
12884    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
12885    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
12886    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
12887    .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; }
12888    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
12889    .ws-divider { display: none; }
12890    .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%; }
12891    .ws-path-link:hover { color:var(--oxide); }
12892    body.dark-theme .ws-path-link { color:var(--oxide); }
12893    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
12894    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12895    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
12896    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12897    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
12898    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
12899    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
12900    .ws-mini-box-lg { flex:2 1 0; }
12901    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
12902    .ws-mini-box-br { flex:1.5 1 0; }
12903    .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); }
12904    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
12905    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
12906    #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; }
12907    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
12908    .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; }
12909    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
12910    .git-source-banner strong { font-weight:800; color:var(--text); }
12911    .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; }
12912    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
12913    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
12914    .git-source-banner a:hover { text-decoration:underline; }
12915    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
12916    .path-scope-sep { background:var(--line); margin:4px 14px; }
12917    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
12918    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
12919    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
12920    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
12921    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
12922    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
12923    .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; }
12924    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12925    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12926    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12927    .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; }
12928    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
12929    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
12930    [data-wb-tip] { cursor:help; }
12931    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
12932    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
12933    .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; }
12934    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
12935    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
12936    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
12937    .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; }
12938    .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); }
12939    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
12940    .side-info-card { padding: 18px; }
12941    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
12942    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
12943    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
12944    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
12945    .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); }
12946    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
12947    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12948    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
12949    .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; }
12950    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
12951    .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; }
12952    .side-stack::-webkit-scrollbar { display: none; }
12953    .step-nav { padding: 20px 16px; }
12954    .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); }
12955    .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; }
12956    .step-button:hover { background: var(--surface-2); }
12957    .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); }
12958    .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; }
12959    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
12960    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
12961    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
12962    .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); }
12963    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
12964    .step-nav-sum-row:last-child { border-bottom:none; }
12965    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
12966    .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; }
12967    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
12968    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
12969    .quick-scan-section { padding: 10px 4px 14px; }
12970    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
12971    .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; }
12972    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
12973    .quick-scan-btn:active { transform:translateY(0); }
12974    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
12975    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
12976    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
12977    @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);} }
12978    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
12979    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
12980    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
12981    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
12982    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
12983    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
12984    .step-button.done .step-check { opacity:1; }
12985    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
12986    .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; }
12987    .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; }
12988    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
12989    .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; }
12990    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
12991    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
12992    .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; }
12993    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
12994    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
12995    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
12996    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
12997    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
12998    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
12999    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
13000    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
13001    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
13002    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
13003    .card-body { padding: 22px; }
13004    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
13005    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
13006    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
13007    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
13008    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
13009    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
13010    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
13011    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
13012    .field { min-width:0; }
13013    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
13014    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; }
13015    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); }
13016    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
13017    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); }
13018    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
13019    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
13020    .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; }
13021    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
13022    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
13023    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
13024    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
13025    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
13026    .input-group.compact { grid-template-columns: 1fr auto auto; }
13027    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
13028    .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)); }
13029    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
13030    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
13031    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
13032    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
13033    .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; }
13034    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
13035    .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; }
13036    .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); }
13037    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
13038    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
13039    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
13040    button.secondary { background: var(--surface); }
13041    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
13042    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
13043    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
13044    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
13045    .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); }
13046    .section + .wizard-actions { border-top: none; padding-top: 0; }
13047    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
13048    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
13049    .field-help-grid.coupled-help { margin-top: 12px; }
13050    .field-help-grid.preset-grid { align-items: start; }
13051    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
13052    .preset-inline-row .field { margin: 0; }
13053    .preset-inline-row .explainer-card { margin: 0; }
13054    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
13055    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
13056    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
13057    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
13058    .preset-kv-row > :last-child { flex:1; min-width:0; }
13059    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
13060    .output-field-row .field { margin: 0; }
13061    .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; }
13062    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
13063    .step3-subtitle { margin-bottom: 10px; max-width: none; }
13064    .counting-intro { margin-bottom: 8px; max-width: none; }
13065    .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; }
13066    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
13067    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
13068    .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; }
13069    .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; }
13070    .section-spacer-top { margin-top: 28px; }
13071    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
13072    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
13073    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
13074    .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); }
13075    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
13076    .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; }
13077    .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; }
13078    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
13079    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
13080    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
13081    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
13082    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
13083    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
13084    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
13085    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
13086    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
13087    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
13088    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
13089    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
13090    .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); }
13091    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
13092    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
13093    .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; }
13094    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
13095    .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; }
13096    .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; }
13097    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
13098    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
13099    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
13100    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
13101    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
13102    .advanced-rule-description strong { color: var(--text); }
13103    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
13104    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
13105    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
13106    .review-link:hover { text-decoration: underline; }
13107    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
13108    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
13109    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
13110    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
13111    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
13112    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
13113    .review-card ul { padding-left: 18px; margin: 0; }
13114    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
13115    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
13116    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
13117    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
13118    .review-card { min-height: 0; }
13119    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
13120    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
13121    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
13122    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
13123    .lang-overflow-chip { position:relative; cursor:default; }
13124    .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; }
13125    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
13126    .git-inline-row { align-items:start; }
13127    .mixed-line-card { display:flex; flex-direction:column; }
13128    .preset-inline-row .toggle-card { justify-content: center; }
13129        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
13130    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
13131    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
13132    .explorer-title { font-size: 18px; font-weight: 850; }
13133    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
13134    .explorer-subtitle.wide { max-width: none; }
13135    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
13136    .better-spacing { align-items:flex-start; justify-content:flex-end; }
13137    .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; }
13138    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
13139    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
13140    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
13141    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
13142    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
13143    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
13144    .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; }
13145    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
13146    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
13147    .scope-stat-button.supported { background: var(--success-bg); }
13148    .scope-stat-button.skipped { background: var(--warn-bg); }
13149    .scope-stat-button.unsupported { background: var(--danger-bg); }
13150    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
13151    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
13152    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
13153    [data-tooltip] { position: relative; }
13154    [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); }
13155    [data-tooltip]:hover::after { display: block; }
13156    .scope-stat-button[data-tooltip] { cursor: pointer; }
13157    .badge[data-tooltip] { cursor: help; }
13158    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
13159    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
13160    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
13161    .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; }
13162    .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; }
13163    code { display:inline-block; margin-top:0; padding:2px 7px; }
13164    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
13165    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
13166    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
13167    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
13168    .language-pill.muted-pill { color: var(--muted); }
13169    button.language-pill { appearance:none; cursor:pointer; }
13170    .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); }
13171    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
13172    .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; }
13173    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
13174    .file-explorer-search-row { margin-left: auto; }
13175    .explorer-filter-select { min-width: 170px; width: 170px; }
13176    .explorer-search { min-width: 300px; width: 300px; }
13177    .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); }
13178    .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; }
13179    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
13180    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
13181    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
13182    .file-explorer-tree { max-height: 640px; overflow:auto; }
13183    .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); }
13184    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
13185    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
13186    .tree-row.hidden-by-filter { display:none !important; }
13187    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
13188    .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; }
13189    .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; }
13190    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
13191    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
13192    .tree-node { display:inline-flex; align-items:center; min-width:0; }
13193    .tree-node-dir { color: var(--text); font-weight: 800; }
13194    .tree-node-supported { color: var(--success-text); }
13195    .tree-node-skipped { color: var(--warn-text); }
13196    .tree-node-unsupported { color: var(--danger-text); }
13197    .tree-node-more { color: var(--muted-2); font-style: italic; }
13198    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
13199    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
13200    .tree-status-cell { display:flex; justify-content:flex-start; }
13201    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
13202    .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; }
13203    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
13204    .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; }
13205    @keyframes prevSpin { to { transform:rotate(360deg); } }
13206    .preview-loading-text { flex:1; min-width:0; }
13207    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
13208    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
13209    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
13210    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
13211    .cov-scan-idle { display:none; }
13212    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
13213    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
13214    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
13215    .cov-scan-title { font-weight:600; font-size:12.5px; }
13216    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
13217    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
13218    .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; }
13219    .cov-scan-use:hover { opacity:.75; }
13220    .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; }
13221    .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; }
13222    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
13223    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
13224    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
13225    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
13226    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
13227    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
13228    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
13229    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
13230    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
13231    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
13232    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
13233    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
13234    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
13235    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
13236    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
13237    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
13238    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
13239    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
13240    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
13241    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
13242    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
13243    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
13244    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
13245    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
13246    .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); }
13247    .loading.active { display:flex; }
13248    .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; }
13249    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
13250    .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; }
13251    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
13252    .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); }
13253    .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
13254    .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
13255    .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
13256    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
13257    @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
13258    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
13259    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
13260    .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; }
13261    .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
13262    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
13263    .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
13264    .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
13265    .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; }
13266    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
13267    .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; }
13268    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
13269    .lc-step.done { color:var(--muted);opacity:0.55; }
13270    .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; }
13271    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
13272    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
13273    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
13274    .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; }
13275    .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; }
13276    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
13277    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
13278    .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; }
13279    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
13280    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
13281    .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; }
13282    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
13283    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
13284    .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; }
13285    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
13286    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
13287    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
13288    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
13289    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
13290    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
13291    .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; }
13292    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
13293    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
13294    .hidden { display:none !important; }
13295    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13296    .site-footer a{color:var(--muted);}
13297    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
13298    @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; } }
13299    .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;}
13300    @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));}}
13301    .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;}
13302    .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; }
13303    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
13304    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
13305    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
13306    .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; }
13307    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
13308    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
13309    .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; }
13310    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
13311    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
13312    .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; }
13313    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
13314    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
13315    .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; }
13316    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
13317    .info-icon-btn:hover { color:var(--text); }
13318    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); }
13319    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
13320    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
13321    .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;}
13322    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
13323    .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;}
13324    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
13325    #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);}
13326    #offline-file-banner.show{display:flex;}
13327    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
13328    #offline-file-banner .ofb-text{flex:1;}
13329    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
13330    #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;}
13331    #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;}
13332    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
13333    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
13334    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
13335    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
13336    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
13337    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
13338    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
13339  </style>
13340</head>
13341<body id="page-top">
13342  <div id="offline-file-banner" role="alert">
13343    <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>
13344    <span class="ofb-text">
13345      Charts, images, and navigation require the oxide-sloc server.
13346      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
13347      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
13348      The metric tables below are fully readable without the server.
13349    </span>
13350    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
13351  </div>
13352  <script>(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>
13353  <div class="background-watermarks" aria-hidden="true">
13354    <img src="/images/logo/logo-text.png" alt="" />
13355    <img src="/images/logo/logo-text.png" alt="" />
13356    <img src="/images/logo/logo-text.png" alt="" />
13357    <img src="/images/logo/logo-text.png" alt="" />
13358    <img src="/images/logo/logo-text.png" alt="" />
13359    <img src="/images/logo/logo-text.png" alt="" />
13360    <img src="/images/logo/logo-text.png" alt="" />
13361    <img src="/images/logo/logo-text.png" alt="" />
13362    <img src="/images/logo/logo-text.png" alt="" />
13363    <img src="/images/logo/logo-text.png" alt="" />
13364    <img src="/images/logo/logo-text.png" alt="" />
13365    <img src="/images/logo/logo-text.png" alt="" />
13366    <img src="/images/logo/logo-text.png" alt="" />
13367    <img src="/images/logo/logo-text.png" alt="" />
13368  </div>
13369  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13370  <div class="top-nav">
13371    <div class="top-nav-inner">
13372      <a class="brand" href="/">
13373        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
13374        <div class="brand-copy">
13375          <div class="brand-title">OxideSLOC</div>
13376          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
13377        </div>
13378      </a>
13379      <div class="nav-project-slot">
13380        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
13381          <span class="nav-project-label">Project</span>
13382          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
13383        </div>
13384      </div>
13385      <div class="nav-status">
13386        <a class="nav-pill" href="/">Home</a>
13387        <div class="nav-dropdown">
13388          <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>
13389          <div class="nav-dropdown-menu">
13390            <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>
13391          </div>
13392        </div>
13393        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13394        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13395        <div class="nav-dropdown">
13396          <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>
13397          <div class="nav-dropdown-menu">
13398            <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>
13399          </div>
13400        </div>
13401        <div class="server-status-wrap" id="server-status-wrap">
13402          <div class="nav-pill server-online-pill" id="server-status-pill">
13403            <span class="status-dot" id="status-dot"></span>
13404            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
13405            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13406          </div>
13407          <div class="server-status-tip">
13408            {% if server_mode %}
13409            OxideSLOC is running in server mode — accessible on your LAN.
13410            {% else %}
13411            OxideSLOC is running locally — only accessible from this machine.
13412            {% endif %}
13413            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13414          </div>
13415        </div>
13416        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13417          <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>
13418        </button>
13419        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13420          <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>
13421          <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>
13422        </button>
13423      </div>
13424    </div>
13425  </div>
13426
13427  <div class="loading" id="loading">
13428    <div class="loading-card">
13429      <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>
13430      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
13431      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
13432      <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>
13433      <div class="lc-steps" id="lc-steps">
13434        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
13435        <div class="lc-step-arrow">›</div>
13436        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
13437        <div class="lc-step-arrow">›</div>
13438        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
13439        <div class="lc-step-arrow">›</div>
13440        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
13441      </div>
13442      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
13443      <div class="lc-metrics" id="lc-metrics">
13444        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
13445        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
13446        <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>
13447        <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>
13448      </div>
13449      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
13450      <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>
13451      <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>
13452      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
13453      <div class="lc-actions hidden" id="lc-actions">
13454        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
13455        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
13456      </div>
13457      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
13458        <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>
13459        Cancel scan
13460      </button>
13461    </div>
13462  </div>
13463
13464  <div class="page">
13465    <div class="workbench-strip">
13466      <div class="workbench-box wb-stats">
13467        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
13468          <span class="wb-stats-title">Analysis session</span>
13469        </div>
13470        <div class="ws-left">
13471          <div class="ws-stat ws-stat-analyzers">
13472            <span class="ws-label">Analyzers</span>
13473            <span class="ws-value">
13474              <span class="ws-badge">41 languages</span>
13475            </span>
13476            <div class="ws-lang-tooltip">
13477              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
13478              <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>
13479              <div class="ws-lang-grid">
13480                <span class="ws-lang-item">Assembly</span>
13481                <span class="ws-lang-item">C</span>
13482                <span class="ws-lang-item">C++</span>
13483                <span class="ws-lang-item">C#</span>
13484                <span class="ws-lang-item">Clojure</span>
13485                <span class="ws-lang-item">CSS</span>
13486                <span class="ws-lang-item">Dart</span>
13487                <span class="ws-lang-item">Dockerfile</span>
13488                <span class="ws-lang-item">Elixir</span>
13489                <span class="ws-lang-item">Erlang</span>
13490                <span class="ws-lang-item">F#</span>
13491                <span class="ws-lang-item">Go</span>
13492                <span class="ws-lang-item">Groovy</span>
13493                <span class="ws-lang-item">Haskell</span>
13494                <span class="ws-lang-item">HTML</span>
13495                <span class="ws-lang-item">Java</span>
13496                <span class="ws-lang-item">JavaScript</span>
13497                <span class="ws-lang-item">Julia</span>
13498                <span class="ws-lang-item">Kotlin</span>
13499                <span class="ws-lang-item">Lua</span>
13500                <span class="ws-lang-item">Makefile</span>
13501                <span class="ws-lang-item">Nim</span>
13502                <span class="ws-lang-item">Obj-C</span>
13503                <span class="ws-lang-item">OCaml</span>
13504                <span class="ws-lang-item">Perl</span>
13505                <span class="ws-lang-item">PHP</span>
13506                <span class="ws-lang-item">PowerShell</span>
13507                <span class="ws-lang-item">Python</span>
13508                <span class="ws-lang-item">R</span>
13509                <span class="ws-lang-item">Ruby</span>
13510                <span class="ws-lang-item">Rust</span>
13511                <span class="ws-lang-item">Scala</span>
13512                <span class="ws-lang-item">SCSS</span>
13513                <span class="ws-lang-item">Shell</span>
13514                <span class="ws-lang-item">SQL</span>
13515                <span class="ws-lang-item">Svelte</span>
13516                <span class="ws-lang-item">Swift</span>
13517                <span class="ws-lang-item">TypeScript</span>
13518                <span class="ws-lang-item">Vue</span>
13519                <span class="ws-lang-item">XML</span>
13520                <span class="ws-lang-item">Zig</span>
13521              </div>
13522            </div>
13523          </div>
13524          <div class="ws-divider"></div>
13525          <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>
13526          <div class="ws-divider"></div>
13527          <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.">
13528            <span class="ws-label">Output</span>
13529            <span class="ws-value">
13530              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
13531                <span id="ws-output-root">project/sloc</span>
13532              </button>
13533            </span>
13534          </div>
13535        </div>
13536      </div>
13537      <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.">
13538        <div class="ws-history-label">Scan history</div>
13539        <div class="ws-history-inner">
13540          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
13541            <div class="ws-mini-label">Scans</div>
13542            <div class="ws-mini-value" id="ws-scan-count">—</div>
13543          </div>
13544          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
13545            <div class="ws-mini-label">Last Scan</div>
13546            <div class="ws-mini-value" id="ws-last-scan">—</div>
13547          </div>
13548          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
13549            <div class="ws-mini-label">Branch</div>
13550            <div class="ws-mini-value" id="ws-branch">—</div>
13551          </div>
13552        </div>
13553      </div>
13554    </div>
13555
13556    <div class="layout">
13557      <aside class="side-stack">
13558        <section class="step-nav">
13559        <h3>Guided scan setup</h3>
13560        <div class="sidebar-scroll-divider"></div>
13561        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
13562          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
13563          Top of page
13564        </a>
13565        <div class="sidebar-scroll-divider"></div>
13566        <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>
13567        <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>
13568        <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>
13569        <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>
13570
13571        <div class="step-steps-divider"></div>
13572
13573        <div class="step-nav-info" id="step-nav-info">
13574          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
13575          <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>
13576        </div>
13577
13578        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
13579          <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>
13580          <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>
13581          <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>
13582        </div>
13583
13584        <div class="quick-scan-divider"></div>
13585        <div class="quick-scan-section">
13586          <div class="quick-scan-label">No customization needed?</div>
13587          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
13588            <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>
13589            Quick Scan
13590          </button>
13591          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
13592        </div>
13593
13594        <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>
13595        <div class="sidebar-scroll-divider"></div>
13596        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
13597          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
13598          Skip to bottom
13599        </a>
13600        </section>
13601
13602      </aside>
13603
13604      <section class="card">
13605        <div class="card-header">
13606          <div class="card-title-row">
13607            <div>
13608              <h1 class="card-title">Guided scan configuration</h1>
13609              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
13610            </div>
13611            <div class="wizard-progress" aria-label="Scan setup progress">
13612              <div class="wizard-progress-top">
13613                <span class="wizard-progress-label">Setup progress</span>
13614                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
13615              </div>
13616              <div class="wizard-progress-track">
13617                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
13618              </div>
13619            </div>
13620          </div>
13621        </div>
13622        <div class="card-body">
13623          <form method="post" action="/analyze" id="analyze-form">
13624            <div class="wizard-step active" data-step="1">
13625              <div class="section">
13626                <div class="section-kicker">Step 1</div>
13627                <h2>Select project and preview scope</h2>
13628                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
13629                <div class="field">
13630                  <label for="path">Project path</label>
13631                  {% if !git_repo.is_empty() %}
13632                  <div class="git-source-banner">
13633                    <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>
13634                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
13635                    <a href="/git-browser">← Back to Git Browser</a>
13636                  </div>
13637                  {% endif %}
13638                  <div class="path-scope-grid">
13639                      {% if !git_repo.is_empty() %}
13640                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
13641                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
13642                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
13643                      {% else %}
13644                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
13645                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
13646                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
13647                      {% endif %}
13648                    <div class="path-scope-sep"></div>
13649                    <div class="scope-legend-row">
13650                      <span class="scope-legend-label">Scope legend:</span>
13651                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
13652                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
13653                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
13654                    </div>
13655                  </div>
13656                  {% if git_repo.is_empty() %}
13657                  {% if server_mode %}
13658                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
13659                    ℹ️ Files are compressed and streamed — no fixed size limit.
13660                  </div>
13661                  {% endif %}
13662                  <div class="path-info-row">
13663                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
13664                      <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>
13665                      <span id="project-size-text">Project size: —</span>
13666                    </button>
13667                  </div>
13668                  {% else %}
13669                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
13670                  {% endif %}
13671                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
13672                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
13673                </div>
13674
13675                <div class="scope-preview-divider" aria-hidden="true"></div>
13676
13677                <div id="preview-panel">
13678                  <div class="preview-error">Loading preview...</div>
13679                </div>
13680              </div>
13681
13682              <div class="section" style="margin-top:14px;">
13683                <div class="preset-inline-row git-inline-row">
13684                  <div class="toggle-card" style="margin:0;">
13685                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
13686                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
13687                    <label class="checkbox">
13688                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
13689                      <div>
13690                        <span>Detect and separate git submodules</span>
13691                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
13692                      </div>
13693                    </label>
13694                  </div>
13695                  <div class="explainer-card prominent" style="margin:0;">
13696                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13697                    <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>
13698                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
13699    path = libs/core
13700    url  = https://github.com/org/core.git
13701
13702[submodule "libs/ui"]
13703    path = libs/ui
13704    url  = https://github.com/org/ui.git</div>
13705                  </div>
13706                </div>
13707              </div>
13708
13709              <div class="section">
13710                <div class="field-grid">
13711                  <div class="field">
13712                    <label for="include_globs">Include globs</label>
13713                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
13714                    <div class="hint">Use line-separated or comma-separated patterns when you want to narrow the scan to only certain folders or file types. If you leave this empty, everything under the project path is eligible first, and then exclude rules trim it down.</div>
13715                  </div>
13716                  <div class="field">
13717                    <label for="exclude_globs">Exclude globs</label>
13718                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
13719                    <div id="quick-exclude-chips" class="quick-excl-row">
13720                      <span class="quick-excl-label">Quick add:</span>
13721                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
13722                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
13723                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
13724                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
13725                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
13726                      <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>
13727                    </div>
13728                    <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>
13729                  </div>
13730                </div>
13731                <div class="glob-guidance-grid">
13732                  <div class="glob-guidance-card">
13733                    <strong>How to read them</strong>
13734                    <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>
13735                  </div>
13736                  <div class="glob-guidance-card">
13737                    <strong>Common include examples</strong>
13738                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
13739                  </div>
13740                  <div class="glob-guidance-card">
13741                    <strong>Common exclude examples</strong>
13742                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
13743                  </div>
13744                </div>
13745              </div>
13746
13747              <div class="section" style="margin-top:14px;">
13748                <div class="preset-inline-row git-inline-row">
13749                  <div class="toggle-card" style="margin:0;">
13750                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
13751                    <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>
13752                    <div class="field" style="margin:0;">
13753                      <div class="input-group compact">
13754                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
13755                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
13756                      </div>
13757                      <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>
13758                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
13759                    </div>
13760                  </div>
13761                  <div class="explainer-card prominent" style="margin:0;">
13762                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13763                    <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>
13764                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
13765lcov --capture --directory . --output-file coverage/lcov.info
13766
13767# C / C++ — llvm-cov (LCOV)
13768llvm-profdata merge -sparse default.profraw -o default.profdata
13769llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
13770
13771# C# — coverlet (Cobertura XML)
13772dotnet test --collect:"XPlat Code Coverage"
13773
13774# Python — pytest-cov (Cobertura XML)
13775pytest --cov --cov-report=xml
13776
13777# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
13778./gradlew jacocoTestReport</div>
13779                  </div>
13780                </div>
13781              </div>
13782
13783              <div class="wizard-actions">
13784                <div class="left"></div>
13785                <div class="right">
13786                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
13787                </div>
13788              </div>
13789            </div>
13790
13791            <div class="wizard-step" data-step="2">
13792              <div class="section">
13793                <div class="section-kicker">Step 2</div>
13794                <h2>Choose counting behavior</h2>
13795                <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>
13796<div class="subsection-bar">Primary line classification</div>
13797                <div class="preset-kv-row">
13798                  <div class="toggle-card mixed-line-card" style="margin:0;">
13799                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
13800                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
13801                    <select id="mixed_line_policy" name="mixed_line_policy">
13802                      <option value="code_only">Code only</option>
13803                      <option value="code_and_comment">Code and comment</option>
13804                      <option value="comment_only">Comment only</option>
13805                      <option value="separate_mixed_category">Separate mixed category</option>
13806                    </select>
13807                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
13808                  </div>
13809                  <div class="explainer-card prominent" style="margin:0;">
13810                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
13811                    <div class="explainer-body" id="mixed-policy-description"></div>
13812                    <div class="code-sample" id="mixed-policy-example"></div>
13813                  </div>
13814                </div>
13815              </div>
13816
13817              <div class="subsection-bar">Additional scan rules</div>
13818              <div class="scan-rules-grid">
13819                <div class="preset-inline-row">
13820                  <div class="toggle-card" style="margin:0;">
13821                    <div class="field-help-title">Generated files</div>
13822                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
13823                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13824                  </div>
13825                  <div class="explainer-card prominent" style="margin:0;">
13826                    <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>
13827                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
13828# Files matching codegen patterns are excluded:
13829#   *.generated.cs  *.pb.go  *.g.dart</div>
13830                  </div>
13831                </div>
13832                <div class="preset-inline-row">
13833                  <div class="toggle-card" style="margin:0;">
13834                    <div class="field-help-title">Minified files</div>
13835                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
13836                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13837                  </div>
13838                  <div class="explainer-card prominent" style="margin:0;">
13839                    <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>
13840                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
13841# Heuristic: very long lines + low whitespace ratio
13842#   jquery.min.js  bundle.min.css  → skipped</div>
13843                  </div>
13844                </div>
13845                <div class="preset-inline-row">
13846                  <div class="toggle-card" style="margin:0;">
13847                    <div class="field-help-title">Vendor directories</div>
13848                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
13849                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13850                  </div>
13851                  <div class="explainer-card prominent" style="margin:0;">
13852                    <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>
13853                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
13854# Directories named vendor/ node_modules/ third_party/
13855#   → entire subtree is excluded from totals</div>
13856                  </div>
13857                </div>
13858                <div class="preset-inline-row">
13859                  <div class="toggle-card" style="margin:0;">
13860                    <div class="field-help-title">Lockfiles and manifests</div>
13861                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
13862                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
13863                  </div>
13864                  <div class="explainer-card prominent" style="margin:0;">
13865                    <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>
13866                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
13867# Files like package-lock.json  Cargo.lock  yarn.lock
13868#   → skipped unless this is enabled</div>
13869                  </div>
13870                </div>
13871                <div class="preset-inline-row">
13872                  <div class="toggle-card" style="margin:0;">
13873                    <div class="field-help-title">Binary handling</div>
13874                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
13875                    <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>
13876                  </div>
13877                  <div class="explainer-card prominent" style="margin:0;">
13878                    <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>
13879                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
13880# Detected via long lines + low whitespace heuristic
13881#   .png  .exe  .so  → skipped silently</div>
13882                  </div>
13883                </div>
13884                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
13885                  <div class="toggle-card" style="margin:0;">
13886                    <div class="field-help-title">Python docstrings</div>
13887                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
13888                    <label class="checkbox">
13889                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
13890                      <span>Count as comment-style lines</span>
13891                    </label>
13892                  </div>
13893                  <div class="explainer-card prominent" style="margin:0;">
13894                    <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>
13895                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
13896                  </div>
13897                </div>
13898              </div>
13899              <div class="subsection-bar">IEEE 1045-1992 counting</div>
13900              <div class="scan-rules-grid">
13901                <div class="preset-inline-row">
13902                  <div class="toggle-card" style="margin:0;">
13903                    <div class="field-help-title">Continuation lines</div>
13904                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
13905                    <select name="continuation_line_policy" id="continuation_line_policy">
13906                      <option value="each_physical_line" selected>Each physical line (default)</option>
13907                      <option value="collapse_to_logical">Collapse to logical line</option>
13908                    </select>
13909                  </div>
13910                  <div class="explainer-card prominent" style="margin:0;">
13911                    <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>
13912                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
13913    ((a) &gt; (b) ? (a) : (b))
13914# each_physical_line → 2 SLOC
13915# collapse_to_logical → 1 SLOC</div>
13916                  </div>
13917                </div>
13918                <div class="preset-inline-row">
13919                  <div class="toggle-card" style="margin:0;">
13920                    <div class="field-help-title">Block-comment blanks</div>
13921                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
13922                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
13923                      <option value="count_as_comment" selected>Count as comment (default)</option>
13924                      <option value="count_as_blank">Count as blank</option>
13925                    </select>
13926                  </div>
13927                  <div class="explainer-card prominent" style="margin:0;">
13928                    <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>
13929                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
13930 * Summary line
13931 *              ← blank inside block comment
13932 * Detail line
13933 */
13934# count_as_comment → blank counts toward comments
13935# count_as_blank   → blank counts toward blanks</div>
13936                  </div>
13937                </div>
13938                <div class="preset-inline-row">
13939                  <div class="toggle-card" style="margin:0;">
13940                    <div class="field-help-title">Compiler directives</div>
13941                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
13942                    <select name="count_compiler_directives" id="count_compiler_directives">
13943                      <option value="enabled" selected>Include in code SLOC (default)</option>
13944                      <option value="disabled">Exclude from code SLOC</option>
13945                    </select>
13946                  </div>
13947                  <div class="explainer-card prominent" style="margin:0;">
13948                    <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>
13949                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
13950#define BUF 256     ← compiler directive
13951int main() { … }   ← code
13952# enabled  → 3 code SLOC
13953# disabled → 1 code SLOC + 2 directive lines</div>
13954                  </div>
13955                </div>
13956              </div>
13957
13958              <div class="subsection-bar">Code Style Analysis</div>
13959              <div class="scan-rules-grid">
13960                <div class="preset-inline-row">
13961                  <div class="toggle-card" style="margin:0;">
13962                    <div class="field-help-title">Style analysis</div>
13963                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
13964                    <select name="style_analysis_enabled" id="style_analysis_enabled">
13965                      <option value="enabled" selected>Enabled (default)</option>
13966                      <option value="disabled">Disabled — skip style scoring</option>
13967                    </select>
13968                  </div>
13969                  <div class="explainer-card prominent" style="margin:0;">
13970                    <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>
13971                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
13972# style_analysis_enabled = false  (skip, faster scan)
13973# Disabling removes the Code Style section from the report.</div>
13974                  </div>
13975                </div>
13976                <div class="preset-inline-row">
13977                  <div class="toggle-card" style="margin:0;">
13978                    <div class="field-help-title">Column-width threshold</div>
13979                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
13980                    <select name="style_col_threshold" id="style_col_threshold">
13981                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
13982                      <option value="100">100 columns (Uber Go, Google Java)</option>
13983                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
13984                    </select>
13985                  </div>
13986                  <div class="explainer-card prominent" style="margin:0;">
13987                    <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>
13988                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
13989# style_col_threshold = 100 (Uber Go, Google Java)
13990# style_col_threshold = 120 (Uber Go max, Kotlin)
13991# Files where &lt;= 5% of lines exceed the limit
13992# are counted as "N-col compliant" in the report.</div>
13993                  </div>
13994                </div>
13995                <div class="preset-inline-row">
13996                  <div class="toggle-card" style="margin:0;">
13997                    <div class="field-help-title">Score alert threshold</div>
13998                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
13999                    <select name="style_score_threshold" id="style_score_threshold">
14000                      <option value="0" selected>Off — no threshold (default)</option>
14001                      <option value="40">40% — flag poorly styled files</option>
14002                      <option value="50">50% — flag below-average files</option>
14003                      <option value="60">60% — flag below-good files</option>
14004                      <option value="70">70% — flag below-strong files</option>
14005                    </select>
14006                  </div>
14007                  <div class="explainer-card prominent" style="margin:0;">
14008                    <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>
14009                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
14010# style_score_threshold = 50  (flag files &lt; 50%)
14011# Low-scoring files get a red left-border in the
14012# per-file style breakdown table.</div>
14013                  </div>
14014                </div>
14015              </div>
14016
14017              <div class="always-tracked-tip">
14018                <div class="always-tracked-tip-icon">ℹ</div>
14019                <div class="always-tracked-tip-body">
14020                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
14021                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
14022                  <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>
14023                </div>
14024              </div>
14025
14026              <div class="wizard-actions">
14027                <div class="left">
14028                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
14029                </div>
14030                <div class="right">
14031                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
14032                </div>
14033              </div>
14034            </div>
14035
14036            <div class="wizard-step" data-step="3">
14037              <div class="section">
14038                <div class="section-kicker">Step 3</div>
14039                <h2>Output and report identity</h2>
14040                <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>
14041                <div class="preset-kv-row">
14042                  <div class="toggle-card" style="margin:0;">
14043                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
14044                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
14045                    <select id="scan_preset">
14046                      <option value="balanced">Balanced local scan</option>
14047                      <option value="code_focused">Code focused</option>
14048                      <option value="comment_audit">Comment audit</option>
14049                      <option value="deep_review">Deep review</option>
14050                    </select>
14051                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
14052                  </div>
14053                  <div class="explainer-card">
14054                    <div class="field-help-title">Selected scan preset</div>
14055                    <div class="explainer-body" id="scan-preset-description"></div>
14056                    <div class="preset-summary-row" id="scan-preset-summary"></div>
14057                    <div class="code-sample" id="scan-preset-example"></div>
14058                    <div class="preset-note" id="scan-preset-note"></div>
14059                  </div>
14060                </div>
14061                <hr class="step3-separator" />
14062                <div class="preset-kv-row">
14063                  <div class="toggle-card" style="margin:0;">
14064                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
14065                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
14066                    <select id="artifact_preset">
14067                      <option value="review">Review bundle</option>
14068                      <option value="full">Full bundle</option>
14069                      <option value="html_only">HTML only</option>
14070                      <option value="machine">Machine bundle</option>
14071                    </select>
14072                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
14073                  </div>
14074                  <div class="explainer-card">
14075                    <div class="field-help-title">Selected artifact preset</div>
14076                    <div class="explainer-body" id="artifact-preset-description"></div>
14077                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
14078                    <div class="code-sample" id="artifact-preset-example"></div>
14079                  </div>
14080                </div>
14081              </div>
14082
14083              <div class="section section-spacer-top">
14084                <div class="output-field-row">
14085                  <div class="field">
14086                    <label for="output_dir">Output directory</label>
14087                    {% if server_mode %}
14088                    <div class="input-group compact">
14089                      <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);" />
14090                    </div>
14091                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
14092                    {% else %}
14093                    <div class="input-group compact">
14094                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
14095                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
14096                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
14097                    </div>
14098                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
14099                    {% endif %}
14100                  </div>
14101                  <div class="output-field-aside">
14102                    <strong>Where reports land</strong>
14103                    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.
14104                  </div>
14105                </div>
14106              </div>
14107
14108              <div class="section section-spacer-top">
14109                <div class="output-field-row">
14110                  <div class="field">
14111                    <label for="report_title">Report title</label>
14112                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
14113                    <div class="hint">Appears in HTML and PDF output headers.</div>
14114                  </div>
14115                  <div class="output-field-aside">
14116                    <strong>Shown in exported artifacts</strong>
14117                    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.
14118                  </div>
14119                </div>
14120              </div>
14121
14122              <div class="section section-spacer-top">
14123                <div class="output-field-row">
14124                  <div class="field">
14125                    <label for="report_header_footer">Report header / footer</label>
14126                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
14127                    <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>
14128                  </div>
14129                  <div class="output-field-aside">
14130                    <strong>Page-level identification</strong>
14131                    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.
14132                  </div>
14133                </div>
14134              </div>
14135
14136              <div class="wizard-actions">
14137                <div class="left">
14138                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
14139                </div>
14140                <div class="right">
14141                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
14142                </div>
14143              </div>
14144            </div>
14145
14146            <div class="wizard-step" data-step="4">
14147              <div class="section">
14148                <div class="section-kicker">Step 4</div>
14149                <h2>Review selections and run</h2>
14150                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
14151                <div class="review-grid">
14152                  <div class="review-card highlight">
14153                    <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>
14154                    <ul id="review-scan-summary"></ul>
14155                  </div>
14156                  <div class="review-card highlight">
14157                    <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>
14158                    <ul id="review-count-summary"></ul>
14159                  </div>
14160                  <div class="review-card">
14161                    <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>
14162                    <ul id="review-artifact-summary"></ul>
14163                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
14164                  </div>
14165                  <div class="review-card">
14166                    <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>
14167                    <ul id="review-preview-summary"></ul>
14168                  </div>
14169                </div>
14170              </div>
14171
14172              <div class="wizard-actions">
14173                <div class="left">
14174                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
14175                </div>
14176                <div class="right">
14177                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
14178                </div>
14179              </div>
14180            </div>
14181            {% if server_mode %}
14182            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
14183            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
14184            {% endif %}
14185          </form>
14186        </div>
14187      </section>
14188    </div>
14189  </div>
14190
14191  <script nonce="{{ csp_nonce }}">
14192    (function () {
14193      function startScanPhase() {
14194        var phaseEl = document.getElementById("scan-phase");
14195        if (!phaseEl) return;
14196        var phases = [
14197          "Discovering files...",
14198          "Decoding file encodings...",
14199          "Detecting languages...",
14200          "Analyzing source lines...",
14201          "Applying counting policies...",
14202          "Aggregating results...",
14203          "Rendering report..."
14204        ];
14205        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
14206        var i = 0;
14207        function next() {
14208          phaseEl.style.opacity = "0";
14209          setTimeout(function () {
14210            phaseEl.textContent = phases[i];
14211            phaseEl.style.opacity = "0.85";
14212            var delay = durations[i] || 1800;
14213            i++;
14214            if (i < phases.length) { setTimeout(next, delay); }
14215          }, 200);
14216        }
14217        next();
14218      }
14219
14220      var form = document.getElementById("analyze-form");
14221      var loading = document.getElementById("loading");
14222      var submitButton = document.getElementById("submit-button");
14223      var pathInput = document.getElementById("path");
14224      var GIT_MODE = !!(pathInput && pathInput.readOnly);
14225      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
14226      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
14227      var outputDirInput = document.getElementById("output_dir");
14228      var reportTitleInput = document.getElementById("report_title");
14229      var previewPanel = document.getElementById("preview-panel");
14230      var refreshButton = document.getElementById("refresh-preview");
14231      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
14232      var useSamplePath = document.getElementById("use-sample-path");
14233      var useDefaultOutput = document.getElementById("use-default-output");
14234      var browsePath = document.getElementById("browse-path");
14235      var browseOutputDir = document.getElementById("browse-output-dir");
14236      var browseCoverage = document.getElementById("browse-coverage");
14237      var coverageInput = document.getElementById("coverage_file");
14238      var covScanStatus = document.getElementById("cov-scan-status");
14239      var coverageSuggestTimer = null;
14240      var covAutoFilled = false;
14241      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
14242      function fmtBytes(b) {
14243        b = Number(b) || 0;
14244        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
14245        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
14246        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
14247        return b + ' B';
14248      }
14249      var themeToggle = document.getElementById("theme-toggle");
14250
14251      function showBannerToast(msg, isError, opts) {
14252        opts = opts || {};
14253        var t = document.createElement('div');
14254        t.className = isError ? 'toast-error' : 'toast-success';
14255        var topPos = opts.top ? '80px' : null;
14256        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
14257          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
14258          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
14259          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
14260        if (opts.icon) {
14261          var inner = document.createElement('span');
14262          inner.innerHTML = opts.icon + ' ';
14263          t.appendChild(inner);
14264        }
14265        t.appendChild(document.createTextNode(msg));
14266        document.body.appendChild(t);
14267        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
14268      }
14269      var mixedLinePolicy = document.getElementById("mixed_line_policy");
14270      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
14271      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
14272      var scanPreset = document.getElementById("scan_preset");
14273      var artifactPreset = document.getElementById("artifact_preset");
14274      var includeGlobsInput = document.getElementById("include_globs");
14275      var excludeGlobsInput = document.getElementById("exclude_globs");
14276
14277      // Quick-exclude chips — append pattern to exclude_globs textarea.
14278      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
14279        chip.addEventListener("click", function() {
14280          var pattern = chip.getAttribute("data-pattern") || "";
14281          if (!pattern || !excludeGlobsInput) return;
14282          var current = excludeGlobsInput.value.trim();
14283          // For the "skip all" chip, replace any existing dep patterns cleanly.
14284          var patterns = pattern.split("\n");
14285          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
14286          var added = false;
14287          patterns.forEach(function(p) {
14288            p = p.trim();
14289            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
14290          });
14291          if (added) {
14292            excludeGlobsInput.value = lines.join("\n");
14293            excludeGlobsInput.dispatchEvent(new Event("input"));
14294          }
14295          chip.classList.add("active");
14296        });
14297      });
14298
14299      var liveReportTitle = document.getElementById("live-report-title");
14300      var navProjectPill = document.getElementById("nav-project-pill");
14301      var navProjectTitle = document.getElementById("nav-project-title");
14302      var reportTitlePreview = null;
14303      var wizardProgressFill = document.getElementById("wizard-progress-fill");
14304      var wizardProgressValue = document.getElementById("wizard-progress-value");
14305      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
14306      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
14307      var reportTitleTouched = false;
14308      var currentStep = 1;
14309      var previewTimer = null;
14310      var _previewGen = 0;
14311      var quickScanBtn = document.getElementById("quick-scan-btn");
14312
14313      function dismissAnalysisModal() {
14314        if (loading) loading.classList.remove("active");
14315        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14316          var el = document.getElementById(id);
14317          if (el) el.classList.add("hidden");
14318        });
14319        var cancelBtn = document.getElementById("lc-cancel-btn");
14320        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
14321        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
14322        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
14323        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
14324        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");}
14325        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
14326        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14327        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14328        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14329        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14330        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14331      }
14332
14333      var lcDismissBtn = document.getElementById("lc-dismiss");
14334      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
14335
14336      // When the browser restores this page from bfcache (Back button after navigating to results),
14337      // the loading overlay would still be showing its active state. Dismiss it immediately.
14338      window.addEventListener("pageshow", function(e) {
14339        if (e.persisted) { dismissAnalysisModal(); }
14340      });
14341
14342      function startAsyncAnalysis(formData) {
14343        var gitRepo = (formData.get("git_repo") || "").toString();
14344        var gitRef  = (formData.get("git_ref")  || "").toString();
14345        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
14346        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
14347
14348        var pathEl = document.getElementById("lc-path-text");
14349        if (pathEl) pathEl.textContent = displayPath;
14350
14351        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14352          var el = document.getElementById(id);
14353          if (el) el.classList.add("hidden");
14354        });
14355        var cancelBtn = document.getElementById("lc-cancel-btn");
14356        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
14357        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14358        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14359        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14360        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
14361        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
14362        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
14363        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");}
14364        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
14365
14366        if (loading) loading.classList.add("active");
14367
14368        var startTime = Date.now();
14369        var elapsedTimer = setInterval(function() {
14370          var s = Math.floor((Date.now() - startTime) / 1000);
14371          var el = document.getElementById("lc-elapsed");
14372          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
14373        }, 1000);
14374
14375        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
14376
14377        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();}
14378
14379        var PHASE_DESC = {
14380          'Starting': 'Initializing language analyzers and loading configuration…',
14381          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
14382          'Running': 'Running the lexical state machine across all discovered source files…',
14383          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
14384          'Done': 'Analysis complete — loading your results…',
14385          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
14386        };
14387        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
14388        function lcSetPhase(txt) {
14389          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
14390          var desc = document.getElementById("lc-stage-desc");
14391          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
14392          var step = PHASE_STEP[txt] || 1;
14393          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");}
14394        }
14395
14396        function lcShowCancelled() {
14397          clearInterval(elapsedTimer);
14398          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
14399          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
14400          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
14401          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
14402          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
14403          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
14404          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
14405          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
14406          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14407          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14408        }
14409
14410        var lcCancelBtn = document.getElementById("lc-cancel-btn");
14411        if (lcCancelBtn) {
14412          lcCancelBtn.onclick = function() {
14413            if (!activeWaitId) { dismissAnalysisModal(); return; }
14414            lcCancelBtn.disabled = true;
14415            lcCancelBtn.textContent = "Cancelling…";
14416            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
14417              .then(function() { lcShowCancelled(); })
14418              .catch(function() { lcShowCancelled(); });
14419          };
14420        }
14421
14422        function lcShowError(msg) {
14423          clearInterval(elapsedTimer);
14424          lcSetPhase("Failed");
14425          var msgEl = document.getElementById("lc-err-msg");
14426          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
14427          var errEl = document.getElementById("lc-err");
14428          var actEl = document.getElementById("lc-actions");
14429          if (errEl) errEl.classList.remove("hidden");
14430          if (actEl) actEl.classList.remove("hidden");
14431          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14432          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14433        }
14434
14435        function lcPoll(waitId) {
14436          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
14437            .then(function(r) {
14438              if (!r.ok) throw new Error("HTTP " + r.status);
14439              return r.json();
14440            })
14441            .then(function(data) {
14442              pollRetries = 0;
14443              if (data.state === "complete") {
14444                clearInterval(elapsedTimer);
14445                lcSetPhase("Done");
14446                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
14447              } else if (data.state === "failed") {
14448                lcShowError(data.message);
14449              } else if (data.state === "cancelled") {
14450                lcShowCancelled();
14451              } else {
14452                var s = Math.floor((Date.now() - startTime) / 1000);
14453                if (s > 90 && !warnShown) {
14454                  warnShown = true;
14455                  var w = document.getElementById("lc-warn");
14456                  if (w) w.classList.remove("hidden");
14457                }
14458                lcSetPhase(data.phase || "Running");
14459                var fd = data.files_done || 0, ft = data.files_total || 0;
14460                if (ft > 0) {
14461                  var card = document.getElementById("lc-files-card");
14462                  if (card) card.classList.remove("hidden");
14463                  var el = document.getElementById("lc-files");
14464                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
14465                  var now = Date.now();
14466                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
14467                  if (fdelta > 0 && tdelta > 0.4) {
14468                    var fps = Math.round(fdelta / tdelta);
14469                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
14470                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
14471                  }
14472                  lastFd = fd; lastFdTime = now;
14473                }
14474                setTimeout(function() { lcPoll(waitId); }, 1500);
14475              }
14476            })
14477            .catch(function() {
14478              pollRetries++;
14479              if (pollRetries >= 5) {
14480                lcShowError("Lost connection to server. Reload to check status.");
14481              } else {
14482                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
14483              }
14484            });
14485        }
14486
14487        var params = new URLSearchParams(formData);
14488        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
14489          .then(function(r) {
14490            var waitId = r.headers.get("x-wait-id");
14491            if (!waitId) { window.location.href = "/scan"; return; }
14492            activeWaitId = waitId;
14493            setTimeout(function() { lcPoll(waitId); }, 1500);
14494          })
14495          .catch(function(err) {
14496            lcShowError("Could not reach server: " + (err.message || err));
14497          });
14498      }
14499
14500      if (quickScanBtn) {
14501        quickScanBtn.addEventListener("click", function () {
14502          var pathVal = pathInput ? pathInput.value.trim() : "";
14503          if (!pathVal) {
14504            alert("Please enter or browse to a project path first.");
14505            return;
14506          }
14507          quickScanBtn.disabled = true;
14508          quickScanBtn.textContent = "Scanning...";
14509          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
14510          startAsyncAnalysis(new FormData(form));
14511        });
14512      }
14513
14514      var mixedPolicyInfo = {
14515        code_only: {
14516          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.",
14517          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'
14518        },
14519        code_and_comment: {
14520          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.",
14521          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'
14522        },
14523        comment_only: {
14524          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.",
14525          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'
14526        },
14527        separate_mixed_category: {
14528          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.",
14529          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'
14530        }
14531      };
14532
14533      var scanPresetInfo = {
14534        balanced: {
14535          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.",
14536          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
14537          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
14538          note: "Best when you want a stable local overview before making deeper adjustments.",
14539          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14540        },
14541        code_focused: {
14542          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
14543          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
14544          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
14545          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
14546          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14547        },
14548        comment_audit: {
14549          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
14550          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
14551          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
14552          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
14553          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14554        },
14555        deep_review: {
14556          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
14557          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
14558          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
14559          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
14560          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
14561        }
14562      };
14563
14564      var artifactPresetInfo = {
14565        review: {
14566          description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
14567          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
14568          example: "Ideal for a quick local review before sharing results."
14569        },
14570        full: {
14571          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
14572          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
14573          example: "Use when producing a deliverable or storing a snapshot for future comparison."
14574        },
14575        html_only: {
14576          description: "Standalone HTML report only. No PDF generation, no data files.",
14577          chips: ["HTML only"],
14578          example: "Fastest option when you only need to open the report in a browser."
14579        },
14580        machine: {
14581          description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
14582          chips: ["JSON", "CSV", "no HTML", "no PDF"],
14583          example: "Use in CI to capture metrics without generating visual reports."
14584        }
14585      };
14586
14587      function applyArtifactPreset() {
14588        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
14589        if (!info) return;
14590        var descEl = document.getElementById("artifact-preset-description");
14591        var exampleEl = document.getElementById("artifact-preset-example");
14592        if (descEl) descEl.textContent = info.description;
14593        if (exampleEl) exampleEl.textContent = info.example;
14594        renderPresetChips("artifact-preset-summary", info.chips);
14595      }
14596
14597      function applyTheme(theme) {
14598        if (theme === "dark") document.body.classList.add("dark-theme");
14599        else document.body.classList.remove("dark-theme");
14600      }
14601
14602      function loadSavedTheme() {
14603        var saved = null;
14604        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
14605        applyTheme(saved === "dark" ? "dark" : "light");
14606      }
14607
14608      function updateScrollProgress() {
14609        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
14610        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
14611        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
14612        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
14613        var step = Math.min(Math.max(currentStep, 1), 4);
14614        var base = stepBase[step];
14615        var end  = stepEnd[step];
14616
14617        var scrollFrac = 0;
14618        var activePanel = document.querySelector(".wizard-step.active");
14619        if (activePanel) {
14620          var scrollTop = window.scrollY || window.pageYOffset || 0;
14621          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
14622          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
14623          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
14624          var scrolled = scrollTop + viewH - panelTop;
14625          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
14626        }
14627
14628        var percent = Math.round(base + (end - base) * scrollFrac);
14629        percent = Math.min(end, Math.max(base, percent));
14630        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
14631        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
14632      }
14633
14634      function updateWizardProgress() {
14635        updateScrollProgress();
14636      }
14637
14638      var stepDescriptions = [
14639        "Choose a project folder, apply scope filters, and preview which files will be counted.",
14640        "Configure how mixed code-plus-comment lines and docstrings are classified.",
14641        "Pick your output formats, scan preset, and where reports are saved.",
14642        "Review all settings and launch the analysis."
14643      ];
14644
14645      function updateStepNav(step) {
14646        var infoLabel = document.getElementById("step-nav-info-label");
14647        var infoDesc  = document.getElementById("step-nav-info-desc");
14648        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
14649        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
14650      }
14651
14652      function updateSidebarSummary() {
14653        var sumPath    = document.getElementById("sum-path");
14654        var sumPreset  = document.getElementById("sum-preset");
14655        var sumOutput  = document.getElementById("sum-output");
14656        var sidebarSummary = document.getElementById("sidebar-summary");
14657        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
14658        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
14659        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
14660        if (sumPath)   sumPath.textContent   = pathVal   || "—";
14661        if (sumPreset) sumPreset.textContent = presetVal || "—";
14662        if (sumOutput) sumOutput.textContent = outputVal || "—";
14663        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
14664      }
14665
14666      function setStep(step, pushHistory) {
14667        currentStep = step;
14668        stepPanels.forEach(function (panel) {
14669          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
14670        });
14671        stepButtons.forEach(function (button) {
14672          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
14673        });
14674        var layoutEl = document.querySelector(".layout");
14675        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
14676        updateWizardProgress();
14677        updateStepNav(step);
14678        stepButtons.forEach(function(btn) {
14679          var t = Number(btn.getAttribute("data-step-target"));
14680          btn.classList.toggle("done", t < step);
14681        });
14682        updateSidebarSummary();
14683
14684        if (pushHistory !== false) {
14685          try {
14686            history.pushState({ wizardStep: step }, "", "#step" + step);
14687          } catch (e) {}
14688        }
14689
14690        window.scrollTo({ top: 0, behavior: "instant" });
14691      }
14692
14693      window.addEventListener("popstate", function (e) {
14694        if (e.state && e.state.wizardStep) {
14695          setStep(e.state.wizardStep, false);
14696        } else {
14697          var hashMatch = location.hash.match(/^#step([1-4])$/);
14698          if (hashMatch) setStep(Number(hashMatch[1]), false);
14699        }
14700      });
14701
14702      function inferTitleFromPath(value) {
14703        if (!value) return "project";
14704        var cleaned = value.replace(/[\/\\]+$/, "");
14705        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
14706        return parts.length ? parts[parts.length - 1] : value;
14707      }
14708
14709      function updateReportTitleFromPath() {
14710        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
14711        if (!reportTitleTouched) {
14712          reportTitleInput.value = inferred;
14713        }
14714        var title = reportTitleInput.value || inferred;
14715        if (liveReportTitle) liveReportTitle.textContent = title;
14716        if (reportTitlePreview) reportTitlePreview.textContent = title;
14717        document.title = "OxideSLOC | " + title;
14718
14719        var projectPath = (pathInput.value || "").trim();
14720        if (navProjectPill && navProjectTitle) {
14721          if (projectPath.length > 0) {
14722            navProjectTitle.textContent = inferred;
14723            navProjectPill.classList.add("visible");
14724          } else {
14725            navProjectTitle.textContent = "";
14726            navProjectPill.classList.remove("visible");
14727          }
14728        }
14729      }
14730
14731      function updateMixedPolicyUI() {
14732        var key = mixedLinePolicy.value || "code_only";
14733        var info = mixedPolicyInfo[key];
14734        document.getElementById("mixed-policy-description").textContent = info.description;
14735        document.getElementById("mixed-policy-example").textContent = info.example;
14736      }
14737
14738      function updatePythonDocstringUI() {
14739        var checked = !!pythonDocstrings.checked;
14740        document.getElementById("python-docstring-example").textContent = checked
14741          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
14742          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
14743        document.getElementById("python-docstring-live-help").textContent = checked
14744          ? "Enabled: docstrings contribute to comment-style totals."
14745          : "Disabled: docstrings are not counted as comment content.";
14746      }
14747
14748      function renderPresetChips(targetId, chips) {
14749        var target = document.getElementById(targetId);
14750        if (!target) return;
14751        target.innerHTML = (chips || []).map(function (chip) {
14752          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
14753        }).join('');
14754      }
14755
14756      function updatePresetDescriptions() {
14757        var scanInfo = scanPresetInfo[scanPreset.value];
14758        if (!scanInfo) return;
14759        document.getElementById("scan-preset-description").textContent = scanInfo.description;
14760        document.getElementById("scan-preset-example").textContent = scanInfo.example;
14761        document.getElementById("scan-preset-note").textContent = scanInfo.note;
14762        renderPresetChips("scan-preset-summary", scanInfo.chips);
14763      }
14764
14765      function applyScanPreset() {
14766        var info = scanPresetInfo[scanPreset.value];
14767        if (!info || !info.apply) return;
14768        mixedLinePolicy.value = info.apply.mixed;
14769        pythonDocstrings.checked = !!info.apply.docstrings;
14770        document.getElementById("generated_file_detection").value = info.apply.generated;
14771        document.getElementById("minified_file_detection").value = info.apply.minified;
14772        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
14773        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
14774        document.getElementById("binary_file_behavior").value = info.apply.binary;
14775        updateMixedPolicyUI();
14776        updatePythonDocstringUI();
14777      }
14778
14779      function updateReview() {
14780        var scanSummary = document.getElementById("review-scan-summary");
14781        var countSummary = document.getElementById("review-count-summary");
14782        var artifactSummary = document.getElementById("review-artifact-summary");
14783        var outputSummary = document.getElementById("review-output-summary");
14784        var previewSummary = document.getElementById("review-preview-summary");
14785        var readinessSummary = document.getElementById("review-readiness-summary");
14786        var includeText = document.getElementById("include_globs").value.trim();
14787        var excludeText = document.getElementById("exclude_globs").value.trim();
14788        var sidePathPreview = document.getElementById("side-path-preview");
14789        var sideOutputPreview = document.getElementById("side-output-preview");
14790        var sideTitlePreview = document.getElementById("side-title-preview");
14791
14792        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
14793        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
14794        if (sideTitlePreview) {
14795          var rt = document.getElementById("report_title");
14796          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
14797        }
14798
14799        scanSummary.innerHTML = ""
14800          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
14801          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
14802          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
14803
14804        countSummary.innerHTML = ""
14805          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
14806          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
14807          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
14808          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
14809          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
14810          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
14811          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
14812          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
14813
14814        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
14815
14816        outputSummary.innerHTML = ""
14817          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
14818          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
14819
14820        if (previewSummary) {
14821          if (GIT_MODE) {
14822            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>';
14823          } else {
14824          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
14825          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
14826          var statMap = {};
14827          statButtons.forEach(function (button) {
14828            var valueNode = button.querySelector('.scope-stat-value');
14829            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
14830          });
14831          previewSummary.innerHTML = ''
14832            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
14833            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
14834            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
14835            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
14836            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
14837            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
14838
14839          if (readinessSummary) {
14840            readinessSummary.innerHTML = ''
14841              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
14842              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
14843              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
14844          }
14845          } // end else (non-GIT_MODE)
14846        }
14847      }
14848
14849      function escapeHtml(value) {
14850        return String(value)
14851          .replace(/&/g, "&amp;")
14852          .replace(/</g, "&lt;")
14853          .replace(/>/g, "&gt;")
14854          .replace(/"/g, "&quot;")
14855          .replace(/'/g, "&#39;");
14856      }
14857
14858      function isPythonVisible() {
14859        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
14860      }
14861
14862      function syncPythonVisibility() {
14863        var html = previewPanel.textContent || "";
14864        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
14865        pythonWraps.forEach(function (node) {
14866          node.classList.toggle("hidden", !hasPython);
14867        });
14868      }
14869
14870      function attachPreviewInteractions() {
14871        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
14872        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
14873        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
14874        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
14875        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
14876        var searchInput = previewPanel.querySelector("#explorer-search");
14877        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
14878        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
14879        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
14880        var activeFilter = "all";
14881        var activeLanguage = "";
14882        var searchTerm = "";
14883        var currentSortKey = null;
14884        var currentSortOrder = "asc";
14885        var childRows = {};
14886
14887        rows.forEach(function (row) {
14888          var parentId = row.getAttribute("data-parent-id") || "";
14889          var rowId = row.getAttribute("data-row-id") || "";
14890          if (!childRows[parentId]) childRows[parentId] = [];
14891          childRows[parentId].push(rowId);
14892        });
14893
14894        function rowById(id) {
14895          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
14896        }
14897
14898        function hasCollapsedAncestor(row) {
14899          var parentId = row.getAttribute("data-parent-id");
14900          while (parentId) {
14901            var parent = rowById(parentId);
14902            if (!parent) break;
14903            if (parent.getAttribute("data-expanded") === "false") return true;
14904            parentId = parent.getAttribute("data-parent-id");
14905          }
14906          return false;
14907        }
14908
14909        function updateToggleGlyph(row) {
14910          var toggle = row.querySelector(".tree-toggle");
14911          if (!toggle) return;
14912          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
14913        }
14914
14915        function rowSortValue(row, key) {
14916          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
14917        }
14918
14919        function updateSortButtons() {
14920          sortButtons.forEach(function (button) {
14921            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
14922            var indicator = button.querySelector(".tree-sort-indicator");
14923            button.classList.toggle("active", isActive);
14924            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
14925            if (indicator) {
14926              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
14927            }
14928          });
14929        }
14930
14931        function sortSiblingRows() {
14932          if (!treeContainer) {
14933            updateSortButtons();
14934            return;
14935          }
14936
14937          var rowMap = {};
14938          var childrenMap = {};
14939          rows.forEach(function (row) {
14940            var rowId = row.getAttribute("data-row-id");
14941            var parentId = row.getAttribute("data-parent-id") || "";
14942            rowMap[rowId] = row;
14943            if (!childrenMap[parentId]) childrenMap[parentId] = [];
14944            childrenMap[parentId].push(rowId);
14945          });
14946
14947          Object.keys(childrenMap).forEach(function (parentId) {
14948            if (!parentId) return;
14949            childrenMap[parentId].sort(function (a, b) {
14950              var rowA = rowMap[a];
14951              var rowB = rowMap[b];
14952              if (!currentSortKey) {
14953                return Number(a) - Number(b);
14954              }
14955              var valueA = rowSortValue(rowA, currentSortKey);
14956              var valueB = rowSortValue(rowB, currentSortKey);
14957              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
14958              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
14959              var fallbackA = rowSortValue(rowA, "name");
14960              var fallbackB = rowSortValue(rowB, "name");
14961              if (fallbackA < fallbackB) return -1;
14962              if (fallbackA > fallbackB) return 1;
14963              return Number(a) - Number(b);
14964            });
14965          });
14966
14967          var orderedIds = [];
14968          function pushChildren(parentId) {
14969            (childrenMap[parentId] || []).forEach(function (childId) {
14970              orderedIds.push(childId);
14971              pushChildren(childId);
14972            });
14973          }
14974
14975          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
14976            orderedIds.push(topId);
14977            pushChildren(topId);
14978          });
14979
14980          orderedIds.forEach(function (id) {
14981            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
14982          });
14983          updateSortButtons();
14984        }
14985
14986        function updateLanguageButtons() {
14987          languageButtons.forEach(function (button) {
14988            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
14989            var isActive = languageValue === activeLanguage;
14990            button.classList.toggle("active", isActive);
14991          });
14992        }
14993
14994        function rowSelfMatches(row) {
14995          var kind = row.getAttribute("data-kind");
14996          var status = row.getAttribute("data-status");
14997          var language = (row.getAttribute("data-language") || "").toLowerCase();
14998          var name = row.getAttribute("data-name-lower") || "";
14999          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
15000          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
15001          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
15002          var passesLanguage = !activeLanguage || language === activeLanguage;
15003          return passesFilter && passesSearch && passesLanguage;
15004        }
15005
15006        function hasMatchingDescendant(rowId) {
15007          return (childRows[rowId] || []).some(function (childId) {
15008            var childRow = rowById(childId);
15009            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
15010          });
15011        }
15012
15013        function rowMatches(row) {
15014          if (rowSelfMatches(row)) return true;
15015          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
15016        }
15017
15018        function resetViewState() {
15019          activeFilter = "all";
15020          activeLanguage = "";
15021          searchTerm = "";
15022          currentSortKey = null;
15023          currentSortOrder = "asc";
15024          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
15025          if (searchInput) searchInput.value = "";
15026          if (filterSelect) filterSelect.value = "all";
15027          updateLanguageButtons();
15028        }
15029
15030        function applyVisibility() {
15031          rows.forEach(function (row) {
15032            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
15033            row.classList.toggle("hidden-by-filter", !visible);
15034            row.style.display = visible ? "grid" : "none";
15035          });
15036          buttons.forEach(function (button) {
15037            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
15038          });
15039          if (filterSelect) filterSelect.value = activeFilter;
15040        }
15041
15042        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
15043        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
15044        var originalStats = {};
15045        buttons.forEach(function (btn) {
15046          var f = btn.getAttribute('data-filter');
15047          var v = btn.querySelector('.scope-stat-value');
15048          if (f && v) originalStats[f] = v.textContent;
15049        });
15050
15051        function applySubmoduleStats(statsJson) {
15052          try {
15053            var s = JSON.parse(statsJson);
15054            buttons.forEach(function (btn) {
15055              var f = btn.getAttribute('data-filter');
15056              var v = btn.querySelector('.scope-stat-value');
15057              if (!v) return;
15058              if (f === 'dir') v.textContent = s.dirs;
15059              else if (f === 'file') v.textContent = s.files;
15060              else if (f === 'supported') v.textContent = s.supported;
15061              else if (f === 'skipped') v.textContent = s.skipped;
15062              else if (f === 'unsupported') v.textContent = s.unsupported;
15063            });
15064          } catch (e) {}
15065        }
15066
15067        function restoreBaseRepoStats() {
15068          buttons.forEach(function (btn) {
15069            var f = btn.getAttribute('data-filter');
15070            var v = btn.querySelector('.scope-stat-value');
15071            if (v && originalStats[f]) v.textContent = originalStats[f];
15072          });
15073          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
15074          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
15075        }
15076
15077        submoduleChips.forEach(function (chip) {
15078          chip.addEventListener('click', function () {
15079            var statsJson = chip.getAttribute('data-sub-stats');
15080            if (!statsJson) return;
15081            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
15082            chip.classList.add('active');
15083            applySubmoduleStats(statsJson);
15084            if (baseRepoBtn) baseRepoBtn.style.display = '';
15085          });
15086        });
15087
15088        if (baseRepoBtn) {
15089          baseRepoBtn.addEventListener('click', function () {
15090            restoreBaseRepoStats();
15091            resetViewState();
15092            sortSiblingRows();
15093            applyVisibility();
15094          });
15095        }
15096
15097        buttons.forEach(function (button) {
15098          button.addEventListener("click", function () {
15099            var filterValue = button.getAttribute("data-filter") || "all";
15100            if (filterValue === "reset-view") {
15101              restoreBaseRepoStats();
15102              resetViewState();
15103              sortSiblingRows();
15104              applyVisibility();
15105              return;
15106            }
15107            activeFilter = filterValue;
15108            applyVisibility();
15109          });
15110        });
15111
15112        rows.forEach(function (row) {
15113          updateToggleGlyph(row);
15114          var toggle = row.querySelector(".tree-toggle");
15115          if (toggle) {
15116            toggle.addEventListener("click", function () {
15117              var expanded = row.getAttribute("data-expanded") !== "false";
15118              row.setAttribute("data-expanded", expanded ? "false" : "true");
15119              updateToggleGlyph(row);
15120              applyVisibility();
15121            });
15122          }
15123        });
15124
15125        actionButtons.forEach(function (button) {
15126          button.addEventListener("click", function () {
15127            var action = button.getAttribute("data-explorer-action");
15128            if (action === "expand-all") {
15129              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
15130            } else if (action === "collapse-all") {
15131              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
15132            } else if (action === "clear-filters") {
15133              resetViewState();
15134            }
15135            sortSiblingRows();
15136            applyVisibility();
15137          });
15138        });
15139
15140        if (filterSelect) {
15141          filterSelect.addEventListener("change", function () {
15142            activeFilter = filterSelect.value || "all";
15143            applyVisibility();
15144          });
15145        }
15146
15147        languageButtons.forEach(function (button) {
15148          button.addEventListener("click", function () {
15149            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
15150            updateLanguageButtons();
15151            applyVisibility();
15152          });
15153        });
15154
15155        sortButtons.forEach(function (button) {
15156          button.addEventListener("click", function () {
15157            var sortKey = button.getAttribute("data-sort-key");
15158            if (currentSortKey === sortKey) {
15159              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
15160            } else {
15161              currentSortKey = sortKey;
15162              currentSortOrder = "asc";
15163            }
15164            sortSiblingRows();
15165            applyVisibility();
15166          });
15167        });
15168
15169        if (searchInput) {
15170          searchInput.addEventListener("input", function () {
15171            searchTerm = searchInput.value.trim().toLowerCase();
15172            applyVisibility();
15173          });
15174        }
15175
15176        updateLanguageButtons();
15177        sortSiblingRows();
15178        applyVisibility();
15179      }
15180
15181      function loadPreview() {
15182        if (!previewPanel || !pathInput) return;
15183        if (GIT_MODE) {
15184          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>';
15185          return;
15186        }
15187        var path = pathInput.value.trim();
15188        var zeroWarn = document.getElementById('zero-files-warning');
15189        if (!path) {
15190          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
15191          if (zeroWarn) zeroWarn.style.display = 'none';
15192          return;
15193        }
15194        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
15195        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
15196        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
15197        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
15198        var myGen = ++_previewGen;
15199        var _prevMsgs = [
15200          'Scanning directory structure…',
15201          'Detecting file types…',
15202          'Applying include / exclude filters…',
15203          'Estimating file counts…',
15204          'Building scope preview…',
15205          'Almost there…'
15206        ];
15207        var _prevMsgIdx = 0;
15208        var _prevStart = Date.now();
15209        previewPanel.innerHTML =
15210          '<div class="preview-loading">' +
15211          '<div class="preview-spinner"></div>' +
15212          '<div class="preview-loading-text">' +
15213          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
15214          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
15215          '</div></div>';
15216        var _sizeTextEl = document.getElementById('project-size-text');
15217        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
15218        window._previewInterval = setInterval(function() {
15219          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
15220          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
15221          var ml = document.getElementById('plm');
15222          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
15223        }, 1500);
15224        window._previewElapsedTimer = setInterval(function() {
15225          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
15226          var el = document.getElementById('ple');
15227          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
15228        }, 1000);
15229        var previewUrl = "/preview?path=" + encodeURIComponent(path)
15230          + "&include_globs=" + encodeURIComponent(includeValue)
15231          + "&exclude_globs=" + encodeURIComponent(excludeValue);
15232        fetch(previewUrl)
15233          .then(function (response) { return response.text(); })
15234          .then(function (html) {
15235            if (myGen !== _previewGen) return;
15236            clearInterval(window._previewInterval); window._previewInterval = null;
15237            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15238            previewPanel.innerHTML = html;
15239            attachPreviewInteractions();
15240            syncPythonVisibility();
15241            updateReview();
15242            setTimeout(collapseLanguagePills, 50);
15243            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
15244            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
15245            var sizeText = document.getElementById('project-size-text');
15246            var sizeBtn = document.getElementById('project-size-btn');
15247            // In server mode with upload sizes available, keep the compressed/original pair.
15248            if (SERVER_MODE && window._lastUploadSizes) {
15249              var us = window._lastUploadSizes;
15250              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
15251                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
15252              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
15253                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
15254            } else if (sizeText && projectSize) {
15255              sizeText.textContent = 'Project size: ' + projectSize;
15256              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
15257            } else if (sizeText) {
15258              sizeText.textContent = 'Project size: —';
15259            }
15260            if (zeroWarn) {
15261              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
15262              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
15263              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
15264              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
15265              if (supportedCount === 0 && fileCount > 0) {
15266                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).';
15267                zeroWarn.style.display = '';
15268              } else {
15269                zeroWarn.style.display = 'none';
15270              }
15271            }
15272          })
15273          .catch(function (err) {
15274            if (myGen !== _previewGen) return;
15275            clearInterval(window._previewInterval); window._previewInterval = null;
15276            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15277            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
15278          });
15279      }
15280
15281      function pickDirectory(targetInput, kind) {
15282        if (SERVER_MODE) {
15283          if (kind === 'output') {
15284            showBannerToast(
15285              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
15286              false,
15287              { top: true, icon: '📁' }
15288            );
15289            return;
15290          }
15291          var inputEl = kind === 'coverage'
15292            ? document.getElementById('cov-upload-input')
15293            : document.getElementById('dir-upload-input');
15294          if (!inputEl) return;
15295          inputEl.onchange = function () {
15296            var files = inputEl.files;
15297            if (!files || files.length === 0) return;
15298            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
15299            if (browseBtn) browseBtn.disabled = true;
15300
15301            function fileToBase64(file) {
15302              return new Promise(function (resolve, reject) {
15303                var reader = new FileReader();
15304                reader.onload = function () {
15305                  var b64 = reader.result.split(',')[1];
15306                  resolve(b64);
15307                };
15308                reader.onerror = reject;
15309                reader.readAsDataURL(file);
15310              });
15311            }
15312
15313            if (kind === 'coverage') {
15314              var f = files[0];
15315              if (previewPanel && targetInput === pathInput)
15316                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
15317              fileToBase64(f).then(function (b64) {
15318                return fetch('/api/upload-file', {
15319                  method: 'POST',
15320                  headers: { 'Content-Type': 'application/json' },
15321                  body: JSON.stringify({ filename: f.name, content: b64 })
15322                }).then(function (r) { return r.json(); });
15323              })
15324                .then(function (d) {
15325                  if (d && d.tmp_path) {
15326                    if (coverageInput) coverageInput.value = d.tmp_path;
15327                    setCovStatus('idle');
15328                  } else if (d && d.error) { showBannerToast(d.error, true); }
15329                })
15330                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
15331                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
15332            } else {
15333              // ── Filter to source-code files only ─────────────────────────
15334              // Binary, generated, and dependency files (node_modules, .git,
15335              // build artifacts) are skipped so they are never uploaded.
15336              var CODE_EXTS = new Set([
15337                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15338                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15339                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15340                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15341                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15342                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
15343                'tf','hcl','proto','thrift','avsc','graphql','gql'
15344              ]);
15345              var codeFiles = [];
15346              for (var i = 0; i < files.length; i++) {
15347                var f = files[i];
15348                var name = f.name;
15349                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
15350                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
15351                  codeFiles.push(f); continue;
15352                }
15353                var dot = name.lastIndexOf('.');
15354                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
15355              }
15356              // Collect specific .git metadata files for server-side git detection.
15357              // These have no source extension so they are excluded by the loop above,
15358              // but the server needs them to read branch/commit/author without running git.
15359              var gitMetaFiles = [];
15360              for (var i = 0; i < files.length; i++) {
15361                var f = files[i];
15362                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
15363                var gitIdx = rp.indexOf('/.git/');
15364                if (gitIdx < 0) continue;
15365                var gitRel = rp.slice(gitIdx + 1);
15366                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
15367                    gitRel === '.git/logs/HEAD' ||
15368                    gitRel.startsWith('.git/refs/heads/') ||
15369                    gitRel.startsWith('.git/refs/tags/')) {
15370                  gitMetaFiles.push(f);
15371                }
15372              }
15373              var uploadFiles = codeFiles.concat(gitMetaFiles);
15374              var total = files.length;
15375              var kept = codeFiles.length;
15376              if (kept === 0) {
15377                if (previewPanel && targetInput === pathInput)
15378                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
15379                if (browseBtn) browseBtn.disabled = false;
15380                inputEl.value = '';
15381                return;
15382              }
15383
15384              // ── Helper: apply upload result to UI ────────────────────────
15385              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
15386              function applyUploadResult(tmpPath, sizes) {
15387                targetInput.value = tmpPath;
15388                scrollInputToEnd(targetInput);
15389                if (sizes && SERVER_MODE) {
15390                  window._lastUploadSizes = sizes;
15391                  // Immediately show both sizes before preview loads.
15392                  var sizeText = document.getElementById('project-size-text');
15393                  var sizeBtn = document.getElementById('project-size-btn');
15394                  if (sizeText) {
15395                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15396                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15397                  }
15398                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15399                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15400                }
15401                if (targetInput === pathInput) {
15402                  updateReportTitleFromPath();
15403                  autoSetOutputDir(tmpPath);
15404                  fetchProjectHistory(tmpPath);
15405                  loadPreview();
15406                  suggestCoverageFile(tmpPath);
15407                }
15408                updateReview();
15409                if (browseBtn) browseBtn.disabled = false;
15410                inputEl.value = '';
15411              }
15412
15413              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
15414              if (typeof CompressionStream !== 'undefined') {
15415                if (previewPanel && targetInput === pathInput)
15416                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15417
15418                // Build a minimal POSIX ustar tar header for a single file entry.
15419                function buildUstarHeader(filePath, fileSize) {
15420                  var BLOCK = 512;
15421                  var hdr = new Uint8Array(BLOCK);
15422                  var enc = new TextEncoder();
15423                  function wStr(off, len, s) {
15424                    var b = enc.encode(s);
15425                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
15426                  }
15427                  function wOct(off, len, val) {
15428                    var s = val.toString(8);
15429                    while (s.length < len - 1) s = '0' + s;
15430                    wStr(off, len, s + '\0');
15431                  }
15432                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
15433                  var name = filePath, prefix = '';
15434                  if (filePath.length > 99) {
15435                    var split = filePath.lastIndexOf('/', 154);
15436                    if (split > 0 && filePath.length - split - 1 <= 99) {
15437                      prefix = filePath.substring(0, split);
15438                      name   = filePath.substring(split + 1);
15439                    } else { name = filePath.substring(0, 99); }
15440                  }
15441                  wStr(0,   100, name);          // name
15442                  wOct(100,   8, 0o000644);      // mode
15443                  wOct(108,   8, 0);             // uid
15444                  wOct(116,   8, 0);             // gid
15445                  wOct(124,  12, fileSize);      // size
15446                  wOct(136,  12, 0);             // mtime (epoch)
15447                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
15448                  hdr[156] = 48;                 // type flag '0' = regular file
15449                  wStr(157, 100, '');            // linkname
15450                  wStr(257,   6, 'ustar');       // magic
15451                  wStr(263,   2, '00');          // version
15452                  wStr(265,  32, '');            // uname
15453                  wStr(297,  32, '');            // gname
15454                  wOct(329,   8, 0);             // devmajor
15455                  wOct(337,   8, 0);             // devminor
15456                  wStr(345, 155, prefix);        // prefix
15457                  // Compute checksum (sum of all bytes, placeholder = 32).
15458                  var chk = 0;
15459                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15460                  var cs = chk.toString(8);
15461                  while (cs.length < 6) cs = '0' + cs;
15462                  wStr(148, 8, cs + '\0 ');
15463                  return hdr;
15464                }
15465
15466                // Build tar.gz one file at a time, piping through CompressionStream.
15467                // RAM usage = compressed output buffer + one file at a time.
15468                (async function () {
15469                  try {
15470                    var BLOCK = 512;
15471                    var cs     = new CompressionStream('gzip');
15472                    var writer = cs.writable.getWriter();
15473                    var chunks = [];
15474                    var reader = cs.readable.getReader();
15475                    var collecting = (async function () {
15476                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
15477                    })();
15478
15479                    for (var i = 0; i < uploadFiles.length; i++) {
15480                      var file = uploadFiles[i];
15481                      var path = file.webkitRelativePath || file.name;
15482                      var buf  = await file.arrayBuffer();
15483                      var data = new Uint8Array(buf);
15484                      // Header block
15485                      await writer.write(buildUstarHeader(path, data.length));
15486                      // Data padded to 512-byte boundary
15487                      if (data.length > 0) {
15488                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
15489                        var block  = new Uint8Array(padded);
15490                        block.set(data);
15491                        await writer.write(block);
15492                      }
15493                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
15494                        if (previewPanel && targetInput === pathInput)
15495                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15496                      }
15497                    }
15498                    // End-of-archive: two 512-byte zero blocks
15499                    await writer.write(new Uint8Array(BLOCK * 2));
15500                    await writer.close();
15501                    await collecting;
15502
15503                    var blob = new Blob(chunks, { type: 'application/gzip' });
15504                    var sizeMB = (blob.size / 1048576).toFixed(1);
15505                    if (previewPanel && targetInput === pathInput)
15506                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
15507
15508                    var resp = await fetch('/api/upload-tarball', {
15509                      method: 'POST',
15510                      headers: { 'Content-Type': 'application/gzip' },
15511                      body: blob
15512                    });
15513                    var d = await resp.json();
15514                    if (d && d.tmp_path) {
15515                      applyUploadResult(d.tmp_path, {
15516                        compressed_bytes: d.compressed_bytes || 0,
15517                        original_bytes: d.original_bytes || 0
15518                      });
15519                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15520                  } catch (e) {
15521                    showBannerToast('Upload failed: ' + String(e), true);
15522                    if (browseBtn) browseBtn.disabled = false;
15523                    inputEl.value = '';
15524                  }
15525                })();
15526
15527              } else {
15528                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
15529                // Used only on browsers that lack CompressionStream (pre-2023).
15530                var BATCH = 200;
15531                var batches = [];
15532                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
15533                var totalBatches = batches.length;
15534                if (previewPanel && targetInput === pathInput)
15535                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
15536
15537                function sendBatch(idx, currentUploadId, lastTmpPath) {
15538                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
15539                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
15540                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
15541                  Promise.all(batches[idx].map(function (file) {
15542                    return fileToBase64(file).then(function (b64) {
15543                      return { path: file.webkitRelativePath || file.name, content: b64 };
15544                    });
15545                  })).then(function (fileList) {
15546                    var body = { files: fileList };
15547                    if (currentUploadId) body.upload_id = currentUploadId;
15548                    return fetch('/api/upload-directory', {
15549                      method: 'POST', headers: { 'Content-Type': 'application/json' },
15550                      body: JSON.stringify(body)
15551                    }).then(function (r) { return r.json(); });
15552                  }).then(function (d) {
15553                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
15554                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15555                  }).catch(function (e) {
15556                    showBannerToast('Upload failed: ' + String(e), true);
15557                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
15558                  });
15559                }
15560                sendBatch(0, null, '');
15561              }
15562            }
15563          };
15564          inputEl.click();
15565          return;
15566        }
15567
15568        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
15569        if (browseButton) browseButton.disabled = true;
15570
15571        if (previewPanel && targetInput === pathInput) {
15572          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
15573        }
15574
15575        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
15576          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
15577          .then(function (data) {
15578            if (data && data.selected_path) {
15579              targetInput.value = data.selected_path;
15580              scrollInputToEnd(targetInput);
15581
15582              if (targetInput === pathInput) {
15583                updateReportTitleFromPath();
15584                autoSetOutputDir(data.selected_path);
15585                fetchProjectHistory(data.selected_path);
15586                loadPreview();
15587                suggestCoverageFile(data.selected_path);
15588              }
15589
15590              updateReview();
15591            } else if (targetInput === pathInput) {
15592              loadPreview();
15593            }
15594          })
15595          .catch(function () {
15596            window.alert("Directory picker request failed.");
15597            if (previewPanel && targetInput === pathInput) {
15598              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
15599            }
15600          })
15601          .finally(function () {
15602            if (browseButton) browseButton.disabled = false;
15603          });
15604      }
15605
15606      if (themeToggle) {
15607        themeToggle.addEventListener("click", function () {
15608          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
15609          applyTheme(nextTheme);
15610          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
15611        });
15612      }
15613
15614      stepButtons.forEach(function (button) {
15615        button.addEventListener("click", function () {
15616          setStep(Number(button.getAttribute("data-step-target")));
15617        });
15618      });
15619
15620      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
15621        button.addEventListener("click", function () {
15622          setStep(Number(button.getAttribute("data-step-target")) || 1);
15623        });
15624      });
15625
15626      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
15627        button.addEventListener("click", function () {
15628          updateReview();
15629          setStep(Number(button.getAttribute("data-next")));
15630        });
15631      });
15632
15633      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
15634        button.addEventListener("click", function () {
15635          setStep(Number(button.getAttribute("data-prev")));
15636        });
15637      });
15638
15639      document.addEventListener("keydown", function (e) {
15640        var tag = (document.activeElement || {}).tagName || "";
15641        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
15642        if (e.altKey || e.ctrlKey || e.metaKey) return;
15643        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
15644        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
15645      });
15646
15647      if (useSamplePath) {
15648        useSamplePath.addEventListener("click", function () {
15649          pathInput.value = "tests/fixtures/basic";
15650          updateReportTitleFromPath();
15651          autoSetOutputDir("tests/fixtures/basic");
15652          loadPreview();
15653          suggestCoverageFile("tests/fixtures/basic");
15654        });
15655      }
15656
15657      if (useDefaultOutput) {
15658        useDefaultOutput.addEventListener("click", function () {
15659          delete outputDirInput.dataset.userEdited;
15660          autoSetOutputDir(pathInput ? pathInput.value : "");
15661          updateReview();
15662        });
15663      }
15664
15665      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
15666      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
15667
15668      // ── Drag-and-drop directory upload (server mode only) ─────────────────
15669      // Dropping a folder onto the path field bypasses Chrome's
15670      // "Upload X files to this site?" confirmation dialog.
15671      async function readDirRecursively(dirEntry, basePath) {
15672        var reader = dirEntry.createReader();
15673        var all = [];
15674        for (;;) {
15675          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
15676          if (!batch.length) break;
15677          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
15678        }
15679        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
15680        var out = [];
15681        for (var i = 0; i < all.length; i++) {
15682          var sub = all[i];
15683          if (sub.isFile) {
15684            var f = await new Promise(function(res) { sub.file(res); });
15685            out.push({ file: f, path: basePath + '/' + sub.name });
15686          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
15687            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
15688            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
15689          }
15690        }
15691        return out;
15692      }
15693
15694      function setupPathDropZone() {
15695        if (!SERVER_MODE || !pathInput) return;
15696        var CODE_EXTS = new Set([
15697          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15698          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15699          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15700          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15701          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15702          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
15703        ]);
15704        pathInput.addEventListener('dragover', function(e) {
15705          e.preventDefault();
15706          pathInput.classList.add('drag-over');
15707        });
15708        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
15709        pathInput.addEventListener('drop', function(e) {
15710          e.preventDefault();
15711          pathInput.classList.remove('drag-over');
15712          var items = e.dataTransfer.items;
15713          if (!items || !items.length) return;
15714          var dirEntry = null;
15715          for (var i = 0; i < items.length; i++) {
15716            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
15717            if (entry && entry.isDirectory) { dirEntry = entry; break; }
15718          }
15719          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
15720          var btn = browsePath;
15721          if (btn) btn.disabled = true;
15722          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
15723
15724          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
15725            var total = allEntries.length;
15726            var codeEntries = allEntries.filter(function(e) {
15727              var n = e.file.name;
15728              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
15729              var dot = n.lastIndexOf('.');
15730              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
15731            });
15732            var kept = codeEntries.length;
15733            if (kept === 0) {
15734              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
15735              if (btn) btn.disabled = false; return;
15736            }
15737
15738            function finish(tmpPath, sizes) {
15739              pathInput.value = tmpPath;
15740              scrollInputToEnd(pathInput);
15741              if (sizes) {
15742                window._lastUploadSizes = sizes;
15743                var sizeText = document.getElementById('project-size-text');
15744                var sizeBtn = document.getElementById('project-size-btn');
15745                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15746                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15747                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15748                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15749              }
15750              updateReportTitleFromPath();
15751              autoSetOutputDir(tmpPath);
15752              fetchProjectHistory(tmpPath);
15753              loadPreview();
15754              suggestCoverageFile(tmpPath);
15755              updateReview();
15756              if (btn) btn.disabled = false;
15757            }
15758
15759            if (typeof CompressionStream === 'undefined') {
15760              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
15761              if (btn) btn.disabled = false; return;
15762            }
15763
15764            try {
15765              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15766              var BLOCK = 512;
15767              var cs = new CompressionStream('gzip');
15768              var wtr = cs.writable.getWriter();
15769              var chunks = [];
15770              var rdr = cs.readable.getReader();
15771              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
15772
15773              function buildHdr(fp, sz) {
15774                var hdr = new Uint8Array(BLOCK);
15775                var enc = new TextEncoder();
15776                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]; }
15777                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
15778                var nm = fp, pfx = '';
15779                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); } }
15780                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
15781                for (var i = 148; i < 156; i++) hdr[i] = 32;
15782                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);
15783                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15784                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
15785                return hdr;
15786              }
15787
15788              for (var i = 0; i < codeEntries.length; i++) {
15789                var ce = codeEntries[i];
15790                var buf = await ce.file.arrayBuffer();
15791                var data = new Uint8Array(buf);
15792                await wtr.write(buildHdr(ce.path, data.length));
15793                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
15794                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
15795                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15796              }
15797              await wtr.write(new Uint8Array(BLOCK * 2));
15798              await wtr.close();
15799              await collecting;
15800
15801              var blob = new Blob(chunks, { type: 'application/gzip' });
15802              var sizeMB = (blob.size / 1048576).toFixed(1);
15803              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
15804              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
15805              var d = await resp.json();
15806              if (d && d.tmp_path) {
15807                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
15808              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
15809            } catch (err) {
15810              showBannerToast('Upload failed: ' + String(err), true);
15811              if (btn) btn.disabled = false;
15812            }
15813          }).catch(function(err) {
15814            showBannerToast('Could not read folder: ' + String(err), true);
15815            if (btn) btn.disabled = false;
15816          });
15817        });
15818      }
15819      setupPathDropZone();
15820      if (browseCoverage) {
15821        browseCoverage.addEventListener("click", function () {
15822          pickDirectory(coverageInput || pathInput, "coverage");
15823        });
15824      }
15825
15826      function setCovStatus(state, opts) {
15827        if (!covScanStatus) return;
15828        opts = opts || {};
15829        covScanStatus.className = "cov-scan-status cov-scan-" + state;
15830        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
15831        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>';
15832        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>';
15833        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>';
15834        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>';
15835        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
15836        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
15837        if (state === "scanning") {
15838          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
15839        } else if (state === "found") {
15840          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15841          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
15842          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
15843          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
15844        } else if (state === "hint") {
15845          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15846          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
15847          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>';
15848        } else if (state === "none") {
15849          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
15850          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
15851        }
15852        html += '</div></div>';
15853        covScanStatus.innerHTML = html;
15854        if (state === "found") {
15855          var useBtn = covScanStatus.querySelector(".cov-scan-use");
15856          if (useBtn) useBtn.addEventListener("click", function () {
15857            if (coverageInput) coverageInput.value = "";
15858            covAutoFilled = false;
15859            setCovStatus("idle");
15860          });
15861        }
15862      }
15863
15864      function suggestCoverageFile(projectPath) {
15865        if (!coverageInput || !covScanStatus) return;
15866        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15867        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
15868        clearTimeout(coverageSuggestTimer);
15869        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
15870        setCovStatus("scanning");
15871        coverageSuggestTimer = setTimeout(function () {
15872          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
15873            .then(function (r) { return r.json(); })
15874            .then(function (d) {
15875              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15876              if (!d) { setCovStatus("none"); return; }
15877              if (d.found) {
15878                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
15879                setCovStatus("found", { found: d.found, tool: d.tool });
15880              } else if (d.tool && d.hint) {
15881                setCovStatus("hint", { tool: d.tool, hint: d.hint });
15882              } else {
15883                setCovStatus("none");
15884              }
15885            })
15886            .catch(function () { setCovStatus("idle"); });
15887        }, 600);
15888      }
15889
15890      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
15891
15892      if (coverageInput) coverageInput.addEventListener("input", function () {
15893        covAutoFilled = false;
15894        if (!this.value.trim()) setCovStatus("idle");
15895      });
15896
15897      // ── Language pill overflow: collapse to "+N more" chip ─────────────
15898      function collapseLanguagePills() {
15899        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
15900        rows.forEach(function(row) {
15901          // Remove any previous overflow chip
15902          var prev = row.querySelector('.lang-overflow-chip');
15903          if (prev) prev.remove();
15904          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
15905          pills.forEach(function(p) { p.style.display = ''; });
15906          if (!pills.length) return;
15907
15908          // Measure after restoring all pills
15909          var containerRight = row.getBoundingClientRect().right;
15910          var hidden = [];
15911          for (var i = pills.length - 1; i >= 1; i--) {
15912            var rect = pills[i].getBoundingClientRect();
15913            if (rect.right > containerRight + 2) {
15914              hidden.unshift(pills[i]);
15915              pills[i].style.display = 'none';
15916            } else {
15917              break;
15918            }
15919          }
15920
15921          if (hidden.length) {
15922            var chip = document.createElement('button');
15923            chip.type = 'button';
15924            chip.className = 'language-pill lang-overflow-chip';
15925            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
15926            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
15927            row.appendChild(chip);
15928          }
15929        });
15930      }
15931
15932      // Run after preview loads (preview panel populates language pills)
15933      var _origLoadPreviewCb = window.__previewLoaded;
15934      document.addEventListener('previewLoaded', collapseLanguagePills);
15935      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
15936      setTimeout(collapseLanguagePills, 400);
15937
15938      // ── Project history & output dir auto-set ──────────────────────────
15939      var wsOutputRoot   = document.getElementById("ws-output-root");
15940      var wsScanCount    = document.getElementById("ws-scan-count");
15941      var wsLastScan     = document.getElementById("ws-last-scan");
15942      var historyBadge   = document.getElementById("path-history-badge");
15943      var historyTimer   = null;
15944
15945      var wsOutputLink = document.getElementById("ws-output-link");
15946      function syncStripOutputRoot() {
15947        var val = outputDirInput ? outputDirInput.value : "";
15948        var display = val || "project/sloc";
15949        if (wsOutputRoot) wsOutputRoot.textContent = display;
15950        if (wsOutputLink) wsOutputLink.dataset.folder = val;
15951      }
15952
15953      function scrollInputToEnd(input) {
15954        if (!input) return;
15955        // Defer so the DOM has the new value before we measure scroll width.
15956        requestAnimationFrame(function () {
15957          input.scrollLeft = input.scrollWidth;
15958          input.selectionStart = input.selectionEnd = input.value.length;
15959        });
15960      }
15961
15962      function autoSetOutputDir(projectPath) {
15963        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
15964        if (GIT_MODE && GIT_OUTPUT_DIR) {
15965          outputDirInput.value = GIT_OUTPUT_DIR;
15966          scrollInputToEnd(outputDirInput);
15967          syncStripOutputRoot();
15968          updateReview();
15969          return;
15970        }
15971        if (!projectPath || !projectPath.trim()) return;
15972        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
15973        outputDirInput.value = cleaned + "/sloc";
15974        scrollInputToEnd(outputDirInput);
15975        syncStripOutputRoot();
15976        updateReview();
15977      }
15978
15979      var wsBranch = document.getElementById("ws-branch");
15980
15981      function fetchProjectHistory(projectPath) {
15982        if (!projectPath || !projectPath.trim()) {
15983          if (wsScanCount) wsScanCount.textContent = "—";
15984          if (wsLastScan)  wsLastScan.textContent  = "—";
15985          if (wsBranch)    wsBranch.textContent    = "—";
15986          if (historyBadge) historyBadge.style.display = "none";
15987          return;
15988        }
15989        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
15990          .then(function (r) { return r.ok ? r.json() : null; })
15991          .then(function (data) {
15992            if (!data) return;
15993            var countStr = data.scan_count > 0
15994              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
15995              : "never";
15996            var tsStr = data.last_scan_timestamp
15997              ? data.last_scan_timestamp.replace(" UTC","")
15998              : "—";
15999            if (wsScanCount) wsScanCount.textContent = countStr;
16000            if (wsLastScan)  wsLastScan.textContent  = tsStr;
16001            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
16002            if (data.scan_count > 0) {
16003              if (historyBadge) {
16004                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
16005                historyBadge.textContent = data.scan_count + " previous scan" +
16006                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
16007                  "Last: " + (data.last_scan_timestamp || "—") +
16008                  " — " + (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.";
16009                historyBadge.className = "path-history-badge found";
16010                historyBadge.style.display = "";
16011              }
16012            } else {
16013              if (historyBadge) historyBadge.style.display = "none";
16014            }
16015          })
16016          .catch(function () {});
16017      }
16018
16019      function onPathChange() {
16020        var val = pathInput ? pathInput.value : "";
16021        // Discard stale upload sizes when the user edits the path manually.
16022        window._lastUploadSizes = null;
16023        updateReportTitleFromPath();
16024        autoSetOutputDir(val);
16025        updateSidebarSummary();
16026        clearTimeout(historyTimer);
16027        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
16028        if (previewTimer) clearTimeout(previewTimer);
16029        previewTimer = setTimeout(loadPreview, 280);
16030        suggestCoverageFile(val);
16031      }
16032
16033      if (pathInput) {
16034        pathInput.addEventListener("input", onPathChange);
16035      }
16036
16037      if (outputDirInput) {
16038        outputDirInput.addEventListener("input", function () {
16039          outputDirInput.dataset.userEdited = "1";
16040          syncStripOutputRoot();
16041          updateReview();
16042        });
16043      }
16044
16045      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
16046        if (!node) return;
16047        node.addEventListener("input", function () {
16048          updateReview();
16049          if (previewTimer) clearTimeout(previewTimer);
16050          previewTimer = setTimeout(loadPreview, 280);
16051        });
16052      });
16053
16054      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
16055        var node = document.getElementById(id);
16056        if (node) node.addEventListener("change", updateReview);
16057      });
16058
16059      if (reportTitleInput) {
16060        reportTitleInput.addEventListener("input", function () {
16061          reportTitleTouched = reportTitleInput.value.trim().length > 0;
16062          updateReportTitleFromPath();
16063          updateReview();
16064        });
16065      }
16066
16067      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
16068      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
16069      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
16070      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
16071
16072      if (coverageInput) {
16073        coverageInput.addEventListener("input", function () {
16074          if (coverageInput.value.trim()) setCovStatus("idle");
16075        });
16076      }
16077
16078      if (form && loading && submitButton) {
16079        form.addEventListener("submit", function (e) {
16080          e.preventDefault();
16081          submitButton.disabled = true;
16082          submitButton.textContent = "Scanning...";
16083          startAsyncAnalysis(new FormData(form));
16084        });
16085      }
16086
16087      function openPath(folder) {
16088        if (!folder) return;
16089        fetch('/open-path?path=' + encodeURIComponent(folder))
16090          .then(function (r) { return r.json(); })
16091          .then(function (d) {
16092            if (d && d.server_mode_disabled)
16093              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16094          })
16095          .catch(function () {});
16096      }
16097
16098      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16099        btn.addEventListener('click', function () {
16100          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
16101        });
16102      });
16103
16104      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
16105      if (wsOutputLink) {
16106        wsOutputLink.addEventListener('click', function () {
16107          openPath(wsOutputLink.dataset.folder || '');
16108        });
16109      }
16110
16111      loadSavedTheme();
16112      updateMixedPolicyUI();
16113      updatePythonDocstringUI();
16114      applyScanPreset();
16115      updatePresetDescriptions();
16116      applyArtifactPreset();
16117      updateReview();
16118      updateScrollProgress(); // initialise bar to 0% (step 1)
16119      window.addEventListener("scroll", updateScrollProgress, { passive: true });
16120      onPathChange();         // seed output dir, history badge, and preview from initial path
16121      updateStepNav(1);
16122
16123      // Restore step from URL hash on initial load (e.g., back-forward cache)
16124      (function() {
16125        var hashMatch = location.hash.match(/^#step([1-4])$/);
16126        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
16127      })();
16128
16129      (function randomizeWatermarks() {
16130        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16131        if (!wms.length) return;
16132        var placed = [];
16133        function tooClose(top, left) {
16134          for (var i = 0; i < placed.length; i++) {
16135            var dt = Math.abs(placed[i][0] - top);
16136            var dl = Math.abs(placed[i][1] - left);
16137            if (dt < 16 && dl < 12) return true;
16138          }
16139          return false;
16140        }
16141        function pick(leftBand) {
16142          for (var attempt = 0; attempt < 50; attempt++) {
16143            var top = Math.random() * 88 + 2;
16144            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16145            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16146          }
16147          var top = Math.random() * 88 + 2;
16148          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16149          placed.push([top, left]);
16150          return [top, left];
16151        }
16152        var half = Math.floor(wms.length / 2);
16153        wms.forEach(function (img, i) {
16154          var pos = pick(i < half);
16155          var size = Math.floor(Math.random() * 80 + 110);
16156          var rot = (Math.random() * 360).toFixed(1);
16157          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
16158          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;
16159        });
16160      })();
16161
16162      (function spawnCodeParticles() {
16163        var container = document.getElementById('code-particles');
16164        if (!container) return;
16165        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'];
16166        for (var i = 0; i < 38; i++) {
16167          (function(idx) {
16168            var el = document.createElement('span');
16169            el.className = 'code-particle';
16170            el.textContent = snippets[idx % snippets.length];
16171            var left = Math.random() * 94 + 2;
16172            var top = Math.random() * 88 + 6;
16173            var dur = (Math.random() * 10 + 9).toFixed(1);
16174            var delay = (Math.random() * 18).toFixed(1);
16175            var rot = (Math.random() * 26 - 13).toFixed(1);
16176            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16177            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';
16178            container.appendChild(el);
16179          })(i);
16180        }
16181      })();
16182    })();
16183  </script>
16184  <script nonce="{{ csp_nonce }}">
16185    (function () {
16186      var raw = {{ prefill_json|safe }};
16187      if (!raw || typeof raw !== 'object' || !raw.path) return;
16188      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
16189      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
16190      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
16191      setVal('path', raw.path || '');
16192      setVal('include_globs', raw.include_globs || '');
16193      setVal('exclude_globs', raw.exclude_globs || '');
16194      setVal('output_dir', raw.output_dir || '');
16195      setVal('report_title', raw.report_title || '');
16196      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
16197      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
16198      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
16199      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
16200      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
16201      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
16202      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
16203      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
16204      setChecked('generate_html', raw.generate_html !== false);
16205      setChecked('generate_pdf', !!raw.generate_pdf);
16206      // Trigger dynamic UI updates after pre-fill.
16207      setTimeout(function () {
16208        var pathEl = document.getElementById('path');
16209        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
16210        var policyEl = document.getElementById('mixed_line_policy');
16211        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
16212      }, 80);
16213    })();
16214  </script>
16215  <script nonce="{{ csp_nonce }}">
16216  (function(){
16217    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'}];
16218    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);});}
16219    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16220    function init(){
16221      var btn=document.getElementById('settings-btn');if(!btn)return;
16222      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16223      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>';
16224      document.body.appendChild(m);
16225      var g=document.getElementById('scheme-grid');
16226      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);});
16227      var cl=document.getElementById('settings-close');
16228      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);
16229      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');});
16230      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16231      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16232    }
16233    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16234  }());
16235  </script>
16236  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
16237    <div class="wb-ftip-arrow"></div>
16238    <span id="wb-ftip-text"></span>
16239  </div>
16240  <script nonce="{{ csp_nonce }}">(function(){
16241    var tip=document.getElementById('wb-ftip');
16242    var txt=document.getElementById('wb-ftip-text');
16243    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
16244    if(!tip||!txt)return;
16245    function pos(el){
16246      var r=el.getBoundingClientRect();
16247      tip.style.display='block';
16248      var tw=tip.offsetWidth;
16249      var lx=r.left+r.width/2-tw/2;
16250      if(lx<8)lx=8;
16251      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
16252      tip.style.left=lx+'px';
16253      tip.style.top=(r.bottom+8)+'px';
16254      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';}
16255    }
16256    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
16257      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
16258      el.addEventListener('mouseleave',function(){tip.style.display='none';});
16259    });
16260    window.addEventListener('blur',function(){tip.style.display='none';});
16261    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
16262  })();
16263  (function(){
16264    function fixArtifactHintSpacing(){
16265      var grid=document.querySelector('.artifact-grid');
16266      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
16267    }
16268    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
16269  }());
16270  (function(){
16271    var dot=document.getElementById('status-dot');
16272    var pingEl=document.getElementById('server-ping-ms');
16273    var tipEl=document.getElementById('server-tip-ping');
16274    var fm=document.getElementById('footer-mode');
16275    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)';}}
16276    function doPing(){
16277      var t0=performance.now();
16278      fetch('/healthz',{cache:'no-store'})
16279        .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);})
16280        .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)';}});
16281    }
16282    doPing();
16283    setInterval(doPing,5000);
16284    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');}
16285  })();
16286  </script>
16287  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
16288  <footer class="site-footer">
16289    local code analysis - metrics, history and reports
16290    &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>
16291    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16292    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16293    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16294    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16295  </footer>
16296</body>
16297</html>
16298"##,
16299    ext = "html"
16300)]
16301struct IndexTemplate {
16302    version: &'static str,
16303    prefill_json: String,
16304    csp_nonce: String,
16305    git_repo: String,
16306    git_ref: String,
16307    git_label_json: String,
16308    git_output_dir_json: String,
16309    server_mode: bool,
16310}
16311
16312// ── SplashTemplate ────────────────────────────────────────────────────────────
16313
16314#[derive(Template)]
16315#[template(
16316    source = r##"
16317<!doctype html>
16318<html lang="en">
16319<head>
16320  <meta charset="utf-8">
16321  <meta name="viewport" content="width=device-width, initial-scale=1">
16322  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
16323  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16324  <style nonce="{{ csp_nonce }}">
16325    :root {
16326      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
16327      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16328      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16329      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16330      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16331    }
16332    body.dark-theme {
16333      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16334      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16335    }
16336    *{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;}
16337    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16338    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16339    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16340    .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;}
16341    @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));}}
16342    .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);}
16343    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16344    .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));}
16345    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16346    .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;}
16347    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16348    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16349    @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; } }
16350    .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;}
16351    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16352    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16353    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16354    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16355    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16356    .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;}
16357    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16358    .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);}
16359    .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;}
16360    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16361    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16362    .settings-modal-body{padding:14px 16px 16px;}
16363    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16364    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16365    .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;}
16366    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16367    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16368    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16369    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16370    .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;}
16371    .tz-select:focus{border-color:var(--oxide);}
16372    .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;}
16373    .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;}
16374    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
16375    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
16376    .hero{text-align:center;margin:0 auto 18px;}
16377    .hero-logo-wrap{display:inline-block;cursor:default;}
16378    .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;}
16379    .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;}
16380    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
16381    .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;}
16382    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%);}
16383    .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;
16384      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
16385      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
16386      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;}
16387    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
16388    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
16389    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;}
16390    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
16391    .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;}
16392    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
16393    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
16394    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
16395    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
16396    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
16397    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
16398    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
16399    .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;}
16400    .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;}
16401    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16402    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
16403    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16404    .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);}
16405    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
16406    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
16407    .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);}
16408    .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);}
16409    .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);}
16410    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
16411    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
16412    .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;}
16413    body.dark-theme .action-card-cta{color:var(--oxide);}
16414    .action-card.view .action-card-cta{color:var(--accent-2);}
16415    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
16416    .action-card.compare .action-card-cta{color:#7c3aed;}
16417    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
16418    .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);}
16419    .action-card.git-tools .action-card-cta{color:#15803d;}
16420    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
16421    .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);}
16422    .action-card.trend .action-card-cta{color:#0e7490;}
16423    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
16424    .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);}
16425    .action-card.automation .action-card-cta{color:#b45309;}
16426    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
16427    .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);}
16428    .action-card.test-metrics .action-card-cta{color:#be185d;}
16429    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
16430    .action-card:hover .action-card-cta{gap:12px;}
16431    .action-card.card-split{flex-direction:row;align-items:stretch;}
16432    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
16433    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
16434    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
16435    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
16436    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
16437    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
16438    .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;}
16439    .ac-badge.active{opacity:1;}
16440    .ac-badge.github{border-color:#555;color:#555;}
16441    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
16442    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
16443    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
16444    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
16445    body.dark-theme .ac-right-row{color:var(--muted);}
16446    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
16447    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
16448    .divider{height:1px;background:var(--line);margin:32px 0;}
16449    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
16450    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
16451    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
16452    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
16453      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
16454    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16455    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
16456    body.dark-theme .info-chip-val{color:var(--oxide);}
16457    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
16458    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
16459      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
16460      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
16461    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
16462      border:6px solid transparent;border-top-color:var(--text);}
16463    .info-chip:hover .info-chip-tip{display:block;}
16464    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
16465    .chip-slide.fading{filter:blur(5px);opacity:0;}
16466    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16467    .site-footer a{color:var(--muted);}
16468    .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;}
16469    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
16470    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
16471    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
16472    .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;}
16473    .lan-badge.local{background:var(--oxide-2);}
16474    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
16475    .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);}
16476    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
16477    .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;}
16478    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
16479    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
16480    .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;}
16481    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
16482    .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;}
16483    .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);}
16484    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
16485    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
16486    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
16487    .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;}
16488    @media (max-height: 1100px) {
16489      .page{padding-top:10px;}
16490      .hero{margin-bottom:10px;}
16491      .hero-logo{width:54px;height:60px;}
16492      .hero-logo-shadow{width:42px;}
16493      .hero-title{font-size:28px;}
16494      .hero-subtitle{font-size:13px;}
16495      .card-sections{gap:12px;margin-bottom:6px;}
16496      .card-section-grid-2,.card-section-grid-3{gap:10px;}
16497      .action-card{padding:8px 15px 8px;}
16498      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
16499      .action-card-icon svg{width:18px;height:18px;}
16500      .action-card-title{font-size:13px;}
16501      .action-card-desc{font-size:11px;margin-bottom:6px;}
16502      .action-card-cta{font-size:11px;}
16503      .ac-right-row{font-size:11px;}
16504      .divider{margin:14px 0;}
16505      .info-strip{gap:7px;margin-bottom:8px;}
16506      .info-chip{padding:7px 10px;}
16507      .info-chip-val{font-size:13px;}
16508      .info-chip-label{font-size:9px;}
16509      .site-footer{padding:8px 24px;font-size:12px;}
16510      .lan-local-hint{margin-top:8px;}
16511    }
16512    @media (max-height: 850px) {
16513      .page{padding-top:6px;}
16514      .hero{margin-bottom:6px;}
16515      .hero-logo{width:42px;height:46px;}
16516      .hero-title{font-size:22px;}
16517      .hero-subtitle{font-size:12px;}
16518      .card-sections{gap:10px;}
16519      .action-card-desc{margin-bottom:4px;}
16520      .divider{margin:8px 0;}
16521      .info-strip{margin-bottom:6px;}
16522      .lan-local-hint{margin-top:10px;}
16523    }
16524  </style>
16525</head>
16526<body>
16527  <div class="background-watermarks" aria-hidden="true">
16528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16532    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16533    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16534    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16535  </div>
16536  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16537  <div class="top-nav">
16538    <div class="top-nav-inner">
16539      <a class="brand" href="/">
16540        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16541        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16542      </a>
16543      <div class="nav-right">
16544        <a class="nav-pill" href="/">Home</a>
16545        <div class="nav-dropdown">
16546          <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>
16547          <div class="nav-dropdown-menu">
16548            <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>
16549          </div>
16550        </div>
16551        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16552        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16553        <div class="nav-dropdown">
16554          <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>
16555          <div class="nav-dropdown-menu">
16556            <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>
16557          </div>
16558        </div>
16559        <div class="server-status-wrap" id="server-status-wrap">
16560          <div class="nav-pill server-online-pill" id="server-status-pill">
16561            <span class="status-dot" id="status-dot"></span>
16562            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16563            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16564          </div>
16565          <div class="server-status-tip">
16566            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
16567            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16568          </div>
16569        </div>
16570        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16571          <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>
16572        </button>
16573        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16574          <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>
16575          <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>
16576        </button>
16577      </div>
16578    </div>
16579  </div>
16580
16581  <div class="page">
16582    <div class="hero">
16583      <div class="hero-logo-wrap" id="hero-logo-wrap">
16584        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
16585      </div>
16586      <div class="hero-logo-shadow"></div>
16587      <div class="hero-title-wrap">
16588        <div class="hero-title-aura" aria-hidden="true"></div>
16589        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
16590      </div>
16591      <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>
16592    </div>
16593
16594    <div class="card-sections">
16595
16596      <div>
16597        <div class="card-section-label">Analysis</div>
16598        <div class="card-section-grid-2">
16599          <a class="action-card scan card-split" href="/scan-setup">
16600            <div class="action-card-left">
16601              <div class="action-card-icon">
16602                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
16603              </div>
16604              <div class="action-card-title">Scan Project</div>
16605              <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>
16606              <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>
16607            </div>
16608            <div class="action-card-sep"></div>
16609            <div class="action-card-right">
16610              <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>
16611              <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>
16612              <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>
16613              <div class="ac-right-stat" id="acp-scan-stat"></div>
16614            </div>
16615          </a>
16616          <a class="action-card test-metrics card-split" href="/test-metrics">
16617            <div class="action-card-left">
16618              <div class="action-card-icon">
16619                <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>
16620              </div>
16621              <div class="action-card-title">Test Metrics</div>
16622              <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>
16623              <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>
16624            </div>
16625            <div class="action-card-sep"></div>
16626            <div class="action-card-right">
16627              <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>
16628              <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>
16629              <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>
16630              <div class="ac-right-stat" id="acp-test-stat"></div>
16631            </div>
16632          </a>
16633        </div>
16634      </div>
16635
16636      <div>
16637        <div class="card-section-label">Reports &amp; Insights</div>
16638        <div class="card-section-grid-3">
16639          <a class="action-card view" href="/view-reports">
16640            <div class="action-card-icon">
16641              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
16642            </div>
16643            <div class="action-card-title">View Reports</div>
16644            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
16645            <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>
16646          </a>
16647          <a class="action-card compare" href="/compare-scans">
16648            <div class="action-card-icon">
16649              <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>
16650            </div>
16651            <div class="action-card-title">Compare Scans</div>
16652            <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>
16653            <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>
16654          </a>
16655          <a class="action-card trend" href="/trend-reports">
16656            <div class="action-card-icon">
16657              <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>
16658            </div>
16659            <div class="action-card-title">Trend Report</div>
16660            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
16661            <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>
16662          </a>
16663        </div>
16664      </div>
16665
16666      <div>
16667        <div class="card-section-label">Developer Tools</div>
16668        <div class="card-section-grid-2">
16669          <a class="action-card git-tools card-split" href="/git-browser">
16670            <div class="action-card-left">
16671              <div class="action-card-icon">
16672                <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>
16673              </div>
16674              <div class="action-card-title">Git Browser</div>
16675              <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>
16676              <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>
16677            </div>
16678            <div class="action-card-sep"></div>
16679            <div class="action-card-right">
16680              <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>
16681              <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>
16682              <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>
16683            </div>
16684          </a>
16685          <a class="action-card automation card-split" href="/integrations">
16686            <div class="action-card-left">
16687              <div class="action-card-icon">
16688                <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>
16689              </div>
16690              <div class="action-card-title">Integrations</div>
16691              <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>
16692              <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>
16693            </div>
16694            <div class="action-card-sep"></div>
16695            <div class="action-card-right">
16696              <div class="ac-badges-grid">
16697                <span class="ac-badge github"     id="acp-gh">GitHub</span>
16698                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
16699                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
16700                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
16701              </div>
16702              <div class="ac-right-stat" id="acp-int-stat"></div>
16703            </div>
16704          </a>
16705        </div>
16706      </div>
16707
16708    </div>
16709
16710    {% if server_mode %}
16711    <div class="lan-card server">
16712      <div class="lan-card-header">
16713        <span class="lan-badge">LAN server</span>
16714        Accessible on your network
16715      </div>
16716      {% if let Some(ip) = lan_ip %}
16717      <div class="lan-url-row">
16718        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
16719        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
16720          <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>
16721          Copy URL
16722        </button>
16723      </div>
16724      <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>
16725      {% if has_api_key %}
16726      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
16727      {% endif %}
16728      {% else %}
16729      <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>
16730      {% endif %}
16731    </div>
16732    {% endif %}
16733
16734    <div class="divider"></div>
16735
16736    <div class="info-strip">
16737      <div class="info-chip">
16738        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
16739        <div class="chip-slide">
16740          <div class="info-chip-val">41</div>
16741          <div class="info-chip-label">Languages</div>
16742        </div>
16743      </div>
16744      <div class="info-chip">
16745        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
16746        <div class="chip-slide">
16747          <div class="info-chip-val">100%</div>
16748          <div class="info-chip-label">Self-contained</div>
16749        </div>
16750      </div>
16751      <div class="info-chip">
16752        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
16753        <div class="chip-slide">
16754          <div class="info-chip-val">HTML+PDF</div>
16755          <div class="info-chip-label">Exportable reports</div>
16756        </div>
16757      </div>
16758      <div class="info-chip">
16759        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
16760        <div class="chip-slide">
16761          <div class="info-chip-val">Webhook</div>
16762          <div class="info-chip-label">3 platforms</div>
16763        </div>
16764      </div>
16765      <div class="info-chip">
16766        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
16767        <div class="chip-slide">
16768          <div class="info-chip-val">IEEE</div>
16769          <div class="info-chip-label">1045-1992</div>
16770        </div>
16771      </div>
16772    </div>
16773
16774    {% if lan_ip.is_none() %}
16775    <div class="lan-local-hint">
16776      <strong>Want teammates on the same network to access this?</strong><br>
16777      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
16778    </div>
16779    {% endif %}
16780  </div>
16781
16782  <footer class="site-footer">
16783    local code analysis - metrics, history and reports
16784    &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>
16785    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16786    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16787    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16788    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16789  </footer>
16790
16791  <script nonce="{{ csp_nonce }}">
16792    (function () {
16793      var storageKey = 'oxide-sloc-theme';
16794      var body = document.body;
16795      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16796      var toggle = document.getElementById('theme-toggle');
16797      if (toggle) toggle.addEventListener('click', function () {
16798        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16799        body.classList.toggle('dark-theme', next === 'dark');
16800        try { localStorage.setItem(storageKey, next); } catch(e) {}
16801      });
16802      var copyBtn = document.getElementById('lan-copy-btn');
16803      if (copyBtn) copyBtn.addEventListener('click', function() {
16804        var btn = this;
16805        var el = document.getElementById('lan-url-val');
16806        if (!el) return;
16807        var url = el.textContent.trim();
16808        if (navigator.clipboard) {
16809          navigator.clipboard.writeText(url).then(function() {
16810            var orig = btn.innerHTML;
16811            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!';
16812            setTimeout(function() { btn.innerHTML = orig; }, 1800);
16813          });
16814        }
16815      });
16816      (function randomizeWatermarks() {
16817        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16818        if (!wms.length) return;
16819        var placed = [];
16820        function tooClose(top, left) {
16821          for (var i = 0; i < placed.length; i++) {
16822            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16823            if (dt < 16 && dl < 12) return true;
16824          }
16825          return false;
16826        }
16827        function pick(leftBand) {
16828          for (var attempt = 0; attempt < 50; attempt++) {
16829            var top = Math.random() * 88 + 2;
16830            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16831            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16832          }
16833          var top = Math.random() * 88 + 2;
16834          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16835          placed.push([top, left]); return [top, left];
16836        }
16837        var half = Math.floor(wms.length / 2);
16838        wms.forEach(function (img, i) {
16839          var pos = pick(i < half);
16840          var size = Math.floor(Math.random() * 100 + 120);
16841          var rot = (Math.random() * 360).toFixed(1);
16842          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16843          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;
16844        });
16845      })();
16846
16847      (function spawnCodeParticles() {
16848        var container = document.getElementById('code-particles');
16849        if (!container) return;
16850        var snippets = [
16851          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
16852          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
16853          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
16854          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
16855          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
16856        ];
16857        var count = 38;
16858        for (var i = 0; i < count; i++) {
16859          (function(idx) {
16860            var el = document.createElement('span');
16861            el.className = 'code-particle';
16862            var text = snippets[idx % snippets.length];
16863            el.textContent = text;
16864            var left = Math.random() * 94 + 2;
16865            var top = Math.random() * 88 + 6;
16866            var dur = (Math.random() * 10 + 9).toFixed(1);
16867            var delay = (Math.random() * 18).toFixed(1);
16868            var rot = (Math.random() * 26 - 13).toFixed(1);
16869            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16870            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
16871              + '--rot:' + rot + 'deg;--op:' + op + ';'
16872              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
16873            container.appendChild(el);
16874          })(i);
16875        }
16876      })();
16877      (function heroAnimations() {
16878        var sub = document.getElementById('hero-subtitle');
16879        if (sub) {
16880          var full = sub.textContent.trim();
16881          sub.textContent = '';
16882          sub.style.opacity = '1';
16883          var cursor = document.createElement('span');
16884          cursor.className = 'hero-cursor';
16885          sub.appendChild(cursor);
16886          var i = 0;
16887          setTimeout(function() {
16888            var iv = setInterval(function() {
16889              if (i < full.length) {
16890                sub.insertBefore(document.createTextNode(full[i]), cursor);
16891                i++;
16892              } else {
16893                clearInterval(iv);
16894                setTimeout(function() {
16895                  cursor.style.transition = 'opacity 1s ease';
16896                  cursor.style.opacity = '0';
16897                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
16898                }, 2400);
16899              }
16900            }, 11);
16901          }, 374);
16902        }
16903      })();
16904      (function logoBob() {
16905        var logo = document.querySelector('.hero-logo');
16906        var shadow = document.querySelector('.hero-logo-shadow');
16907        if (!logo) return;
16908        var cycleStart = null, cycleDur = 3600;
16909        var peakY = -14, peakScale = 1.07, peakRot = 0;
16910        function newCycle() {
16911          cycleDur = 3000 + Math.random() * 1840;
16912          peakY = -(9 + Math.random() * 13.8);
16913          peakScale = 1.04 + Math.random() * 0.081;
16914          peakRot = (Math.random() * 11.5 - 5.75);
16915        }
16916        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
16917        newCycle();
16918        function frame(ts) {
16919          if (cycleStart === null) cycleStart = ts;
16920          var t = (ts - cycleStart) / cycleDur;
16921          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
16922          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
16923          var y = peakY * phase;
16924          var sc = 1 + (peakScale - 1) * phase;
16925          var rot = peakRot * Math.sin(Math.PI * phase);
16926          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
16927          if (shadow) {
16928            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
16929            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
16930          }
16931          requestAnimationFrame(frame);
16932        }
16933        requestAnimationFrame(frame);
16934      })();
16935      (function mouseEffects() {
16936        var heroTitle = document.getElementById('hero-title');
16937        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
16938        function tick() {
16939          raf = null;
16940          if (heroTitle) {
16941            var r = heroTitle.getBoundingClientRect();
16942            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
16943            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
16944            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
16945          }
16946        }
16947        document.addEventListener('mousemove', function(e) {
16948          mx = e.clientX; my = e.clientY;
16949          if (!raf) raf = requestAnimationFrame(tick);
16950        });
16951        document.addEventListener('mouseleave', function() {
16952          if (heroTitle) {
16953            heroTitle.style.transition = 'transform 0.5s ease';
16954            heroTitle.style.transform = '';
16955            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
16956          }
16957        });
16958        document.querySelectorAll('.action-card').forEach(function(card) {
16959          card.addEventListener('mousemove', function(e) {
16960            var rect = card.getBoundingClientRect();
16961            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
16962            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
16963            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
16964            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
16965          });
16966          card.addEventListener('mouseleave', function() {
16967            card.style.transition = '';
16968            card.style.transform = '';
16969          });
16970        });
16971      })();
16972      (function chipSlideshow() {
16973        var slides = [
16974          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
16975          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
16976          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
16977          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
16978          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
16979        ];
16980        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
16981        var indices = [0,0,0,0,0];
16982        var paused = [false,false,false,false,false];
16983        chips.forEach(function(chip, i) {
16984          chip.addEventListener('mouseenter', function() { paused[i] = true; });
16985          chip.addEventListener('mouseleave', function() { paused[i] = false; });
16986        });
16987        function advance(i) {
16988          if (paused[i]) return;
16989          var chip = chips[i];
16990          var inner = chip.querySelector('.chip-slide');
16991          if (!inner) return;
16992          inner.classList.add('fading');
16993          setTimeout(function() {
16994            indices[i] = (indices[i] + 1) % slides[i].length;
16995            var s = slides[i][indices[i]];
16996            chip.querySelector('.info-chip-val').textContent = s.v;
16997            chip.querySelector('.info-chip-label').textContent = s.l;
16998            inner.classList.remove('fading');
16999          }, 720);
17000        }
17001        setInterval(function() {
17002          chips.forEach(function(chip, i) { advance(i); });
17003        }, 6000);
17004      })();
17005      (function cardLiveData() {
17006        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
17007          var el = document.getElementById('acp-scan-stat');
17008          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
17009        }).catch(function(){});
17010        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
17011          var el = document.getElementById('acp-test-stat');
17012          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
17013        }).catch(function(){});
17014        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
17015          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
17016          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
17017          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
17018          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
17019          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
17020          var stat = document.getElementById('acp-int-stat');
17021          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
17022        }).catch(function(){});
17023        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
17024          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
17025        }).catch(function(){});
17026      })();
17027    })();
17028  </script>
17029  <script nonce="{{ csp_nonce }}">
17030  (function(){
17031    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'}];
17032    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);});}
17033    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17034    function init(){
17035      var btn=document.getElementById('settings-btn');if(!btn)return;
17036      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17037      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>';
17038      document.body.appendChild(m);
17039      var g=document.getElementById('scheme-grid');
17040      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);});
17041      var cl=document.getElementById('settings-close');
17042      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);
17043      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');});
17044      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17045      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17046    }
17047    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17048  }());
17049  </script>
17050  <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>
17051</body>
17052</html>
17053"##,
17054    ext = "html"
17055)]
17056struct SplashTemplate {
17057    csp_nonce: String,
17058    server_mode: bool,
17059    lan_ip: Option<String>,
17060    port: u16,
17061    version: &'static str,
17062    has_api_key: bool,
17063}
17064
17065// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
17066
17067#[derive(Template)]
17068#[template(
17069    source = r##"
17070<!doctype html>
17071<html lang="en">
17072<head>
17073  <meta charset="utf-8">
17074  <meta name="viewport" content="width=device-width, initial-scale=1">
17075  <title>OxideSLOC — Start a Scan</title>
17076  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17077  <style nonce="{{ csp_nonce }}">
17078    :root {
17079      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
17080      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17081      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
17082      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17083      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
17084    }
17085    body.dark-theme {
17086      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
17087      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
17088    }
17089    *{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;}
17090    .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);}
17091    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17092    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
17093    .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));}
17094    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
17095    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17096    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17097    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17098    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17099    @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; } }
17100    .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;}
17101    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17102    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17103    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17104    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17105    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17106    .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;}
17107    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17108    .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);}
17109    .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;}
17110    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17111    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17112    .settings-modal-body{padding:14px 16px 16px;}
17113    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17114    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17115    .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;}
17116    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17117    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17118    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17119    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17120    .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;}
17121    .tz-select:focus{border-color:var(--oxide);}
17122    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
17123    .page-header{text-align:center;margin-bottom:16px;}
17124    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
17125    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
17126    /* Cards */
17127    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
17128    .option-card-wrap{position:relative;}
17129    .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;}
17130    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
17131    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
17132    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
17133    .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;}
17134    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
17135    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
17136    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
17137    .card-top-row{display:flex;align-items:center;gap:20px;}
17138    /* Two-column layout inside each card */
17139    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
17140    .card-left{display:flex;align-items:flex-start;min-width:0;}
17141    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
17142    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
17143    .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);}
17144    .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);}
17145    .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);}
17146    .card-text{min-width:0;}
17147    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
17148    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
17149    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
17150    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
17151    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
17152    /* Right CTA column */
17153    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
17154    .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;}
17155    /* Re-scan count badge */
17156    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
17157    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
17158    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
17159    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
17160    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
17161    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
17162    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
17163    body.dark-theme .btn-secondary{color:var(--oxide);}
17164    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
17165    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
17166    /* File input overlay — must be full-width so it aligns with other card-right buttons */
17167    .file-input-wrap{position:relative;width:100%;}
17168    .file-input-wrap .btn{width:100%;}
17169    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
17170    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17171    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17172    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17173    .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;}
17174    @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));}}
17175    /* Recent list (card 3 — full-width section below header) */
17176    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
17177    .recent-list{display:flex;flex-direction:column;gap:8px;}
17178    .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;}
17179    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
17180    .recent-item-info{flex:1;min-width:0;}
17181    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
17182    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
17183    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
17184    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
17185    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17186    .site-footer a{color:var(--muted);}
17187    @media(max-width:680px){
17188      .card-body{grid-template-columns:1fr;}
17189      .card-right{flex-direction:row;flex-wrap:wrap;}
17190      .btn{flex:1;}
17191    }
17192    .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;}
17193    .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;}
17194    .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;}
17195  </style>
17196</head>
17197<body>
17198  <div class="background-watermarks" aria-hidden="true">
17199    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17200    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17201    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17202    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17203    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17204    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17205    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17206  </div>
17207  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17208  <div class="top-nav">
17209    <div class="top-nav-inner">
17210      <a class="brand" href="/">
17211        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17212        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17213      </a>
17214      <div class="nav-right">
17215        <a class="nav-pill" href="/">Home</a>
17216        <div class="nav-dropdown">
17217          <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>
17218          <div class="nav-dropdown-menu">
17219            <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>
17220          </div>
17221        </div>
17222        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17223        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17224        <div class="nav-dropdown">
17225          <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>
17226          <div class="nav-dropdown-menu">
17227            <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>
17228          </div>
17229        </div>
17230        <div class="server-status-wrap" id="server-status-wrap">
17231          <div class="nav-pill server-online-pill" id="server-status-pill">
17232            <span class="status-dot" id="status-dot"></span>
17233            <span id="server-status-label">Server</span>
17234            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17235          </div>
17236          <div class="server-status-tip">
17237            OxideSLOC is running — accessible on your network.
17238            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17239          </div>
17240        </div>
17241        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17242          <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>
17243        </button>
17244        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17245          <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>
17246          <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>
17247        </button>
17248      </div>
17249    </div>
17250  </div>
17251
17252  <div class="page">
17253    <div class="page-header">
17254      <h1>How would you like to scan?</h1>
17255      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
17256    </div>
17257
17258    <div class="option-grid">
17259
17260      <!-- Option 1: New scan -->
17261      <div class="option-card-wrap">
17262        <div class="option-card">
17263        <div class="option-icon new-scan">
17264          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
17265        </div>
17266        <div class="card-body">
17267          <div class="card-left">
17268            <div class="card-text">
17269              <div class="option-title">Start a new scan</div>
17270              <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>
17271              <ul class="feature-list">
17272                <li>Live project scope preview before you run</li>
17273                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
17274                <li>HTML, PDF, and JSON output — your choice</li>
17275              </ul>
17276            </div>
17277          </div>
17278          <div class="card-right">
17279            <a class="btn btn-primary" href="/scan">
17280              Configure &amp; scan
17281              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
17282            </a>
17283            <p class="card-tip">Full 4-step setup · all options</p>
17284          </div>
17285        </div>
17286        </div>
17287      </div>
17288
17289      <!-- Option 2: Load from config file -->
17290      <div class="option-card-wrap">
17291        <div class="option-card">
17292        <div class="option-icon load-config">
17293          <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>
17294        </div>
17295        <div class="card-body">
17296          <div class="card-left">
17297            <div class="card-text">
17298              <div class="option-title">Load a saved config</div>
17299              <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>
17300              <ul class="feature-list">
17301                <li>All 15 settings restored from the file</li>
17302                <li>Fully editable — change path or output dir</li>
17303                <li>Works with any scan-config.json</li>
17304              </ul>
17305            </div>
17306          </div>
17307          <div class="card-right">
17308            <div class="file-input-wrap">
17309              <button class="btn btn-secondary" id="load-config-btn" type="button">
17310                <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>
17311                Choose config file
17312              </button>
17313              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
17314            </div>
17315            <p class="card-tip" id="config-file-name">Exported after every scan</p>
17316          </div>
17317        </div>
17318        </div>
17319      </div>
17320
17321      <!-- Option 3: Re-scan recent project -->
17322      <div class="option-card-wrap">
17323        <div class="option-card" id="recent-card">
17324        <div class="card-top-row">
17325          <div class="option-icon rescan">
17326            <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>
17327          </div>
17328          <div class="card-body">
17329            <div class="card-left">
17330              <div class="card-text">
17331                <div class="option-title">Re-scan a recent project</div>
17332                <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>
17333                <ul class="feature-list">
17334                  <li>All 15+ settings restored from the saved config</li>
17335                  <li>Path and output dir are editable before running</li>
17336                  <li>Only scans with a saved config appear here</li>
17337                </ul>
17338              </div>
17339            </div>
17340            <div class="card-right">
17341              <div class="rescan-count-box">
17342                <div class="rescan-count-num" id="rescan-count-num">—</div>
17343                <div class="rescan-count-label">saved configs</div>
17344              </div>
17345              <a class="btn btn-secondary" href="/view-reports">
17346                <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>
17347                View all runs
17348              </a>
17349              <p class="card-tip">Opens run history</p>
17350            </div>
17351          </div>
17352        </div>
17353        <div class="section-divider"></div>
17354        <div class="recent-list" id="recent-list">
17355          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
17356        </div>
17357        </div>
17358      </div>
17359
17360    </div>
17361  </div>
17362
17363  <footer class="site-footer">
17364    local code analysis - metrics, history and reports
17365    &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>
17366    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17367    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17368    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17369    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17370  </footer>
17371
17372  <script nonce="{{ csp_nonce }}">
17373    (function () {
17374      var storageKey = 'oxide-sloc-theme';
17375      var body = document.body;
17376      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17377      var toggle = document.getElementById('theme-toggle');
17378      if (toggle) toggle.addEventListener('click', function () {
17379        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17380        body.classList.toggle('dark-theme', next === 'dark');
17381        try { localStorage.setItem(storageKey, next); } catch(e) {}
17382      });
17383
17384      (function randomizeWatermarks() {
17385        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17386        if (!wms.length) return;
17387        var placed = [];
17388        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; }
17389        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]; }
17390        var half = Math.floor(wms.length / 2);
17391        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; });
17392      })();
17393      (function spawnCodeParticles() {
17394        var container = document.getElementById('code-particles');
17395        if (!container) return;
17396        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'];
17397        var count = 38;
17398        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); }
17399      })();
17400      // Recent scans data injected from server
17401      var recentScans = {{ recent_scans_json|safe }};
17402
17403      function configToParams(cfg) {
17404        var p = new URLSearchParams();
17405        p.set('prefilled', '1');
17406        if (cfg.path) p.set('path', cfg.path);
17407        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
17408        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
17409        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
17410        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
17411        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
17412        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
17413        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
17414        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
17415        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
17416        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
17417        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
17418        if (cfg.report_title) p.set('report_title', cfg.report_title);
17419        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
17420        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
17421        return p;
17422      }
17423
17424      // Build recent scan list (capped at 3 visible entries)
17425      var list = document.getElementById('recent-list');
17426      var noNote = document.getElementById('no-recent-note');
17427      var hasAny = false;
17428      var MAX_RECENT = 3;
17429      if (Array.isArray(recentScans)) {
17430        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
17431        var shown = 0;
17432        validEntries.forEach(function (entry) {
17433          if (shown >= MAX_RECENT) return;
17434          shown++;
17435          hasAny = true;
17436          var item = document.createElement('div');
17437          item.className = 'recent-item';
17438          item.title = 'Restore all settings and open wizard';
17439          item.innerHTML =
17440            '<div class="recent-item-info">' +
17441              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
17442              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
17443            '</div>' +
17444            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
17445          item.addEventListener('click', function () {
17446            var params = configToParams(entry.config);
17447            window.location.href = '/scan?' + params.toString();
17448          });
17449          list.appendChild(item);
17450        });
17451        if (validEntries.length > MAX_RECENT) {
17452          var moreEl = document.createElement('div');
17453          moreEl.className = 'recent-more-link';
17454          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
17455          list.appendChild(moreEl);
17456        }
17457      }
17458      if (hasAny && noNote) noNote.style.display = 'none';
17459      // Update count badge
17460      var countEl = document.getElementById('rescan-count-num');
17461      if (countEl) {
17462        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
17463        countEl.textContent = total > 0 ? total : '0';
17464      }
17465
17466      // Config file loader
17467      var fileInput = document.getElementById('config-file-input');
17468      var fileName = document.getElementById('config-file-name');
17469      var loadBtn = document.getElementById('load-config-btn');
17470      // Wire the visible button to open the hidden file picker.
17471      if (loadBtn && fileInput) {
17472        loadBtn.addEventListener('click', function () { fileInput.click(); });
17473      }
17474      if (fileInput) {
17475        fileInput.addEventListener('change', function () {
17476          var file = fileInput.files && fileInput.files[0];
17477          if (!file) return;
17478          if (fileName) fileName.textContent = '✓ ' + file.name;
17479          var reader = new FileReader();
17480          reader.onload = function (e) {
17481            try {
17482              var cfg = JSON.parse(e.target.result);
17483              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
17484              var params = configToParams(cfg);
17485              window.location.href = '/scan?' + params.toString();
17486            } catch (err) {
17487              alert('Could not parse config file: ' + err.message);
17488            }
17489          };
17490          reader.readAsText(file);
17491        });
17492      }
17493
17494      function escHtml(s) {
17495        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
17496      }
17497    })();
17498  </script>
17499  <script nonce="{{ csp_nonce }}">
17500  (function(){
17501    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'}];
17502    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);});}
17503    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17504    function init(){
17505      var btn=document.getElementById('settings-btn');if(!btn)return;
17506      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17507      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>';
17508      document.body.appendChild(m);
17509      var g=document.getElementById('scheme-grid');
17510      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);});
17511      var cl=document.getElementById('settings-close');
17512      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);
17513      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');});
17514      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17515      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17516    }
17517    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17518  }());
17519  </script>
17520  <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=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>
17521</body>
17522</html>
17523"##,
17524    ext = "html"
17525)]
17526struct ScanSetupTemplate {
17527    version: &'static str,
17528    recent_scans_json: String,
17529    csp_nonce: String,
17530}
17531
17532#[derive(Template)]
17533#[template(
17534    source = r##"
17535<!doctype html>
17536<html lang="en">
17537<head>
17538  <meta charset="utf-8">
17539  <meta name="viewport" content="width=device-width, initial-scale=1">
17540  <title>OxideSLOC | {{ report_title }} | Report</title>
17541  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17542  <style nonce="{{ csp_nonce }}">
17543    :root {
17544      --radius: 18px;
17545      --bg: #f5efe8;
17546      --surface: rgba(255,255,255,0.82);
17547      --surface-2: #fbf7f2;
17548      --surface-3: #efe6dc;
17549      --line: #e6d0bf;
17550      --line-strong: #dcb89f;
17551      --text: #43342d;
17552      --muted: #7b675b;
17553      --muted-2: #a08777;
17554      --nav: #b85d33;
17555      --nav-2: #7a371b;
17556      --accent: #6f9bff;
17557      --accent-2: #4a78ee;
17558      --oxide: #d37a4c;
17559      --oxide-2: #b35428;
17560      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
17561      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
17562      --success-bg: #e8f5ed;
17563      --success-text: #1a8f47;
17564      --info-bg: #eef3ff;
17565      --info-text: #4467d8;
17566    }
17567
17568    body.dark-theme {
17569      --bg: #1b1511;
17570      --surface: #261c17;
17571      --surface-2: #2d221d;
17572      --surface-3: #372922;
17573      --line: #524238;
17574      --line-strong: #6c5649;
17575      --text: #f5ece6;
17576      --muted: #c7b7aa;
17577      --muted-2: #aa9485;
17578      --nav: #b85d33;
17579      --nav-2: #7a371b;
17580      --accent: #6f9bff;
17581      --accent-2: #4a78ee;
17582      --oxide: #d37a4c;
17583      --oxide-2: #b35428;
17584      --shadow: 0 18px 42px rgba(0,0,0,0.28);
17585      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
17586      --success-bg: #163927;
17587      --success-text: #8fe2a8;
17588      --info-bg: #1c2847;
17589      --info-text: #a9c1ff;
17590    }
17591
17592    * { box-sizing: border-box; }
17593    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); }
17594    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17595    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17596    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17597    .top-nav, .page { position: relative; z-index: 2; }
17598    .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); }
17599    .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; }
17600    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17601    .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)); }
17602    .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; }
17603    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17604    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17605    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17606    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17607    .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; }
17608    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17609    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17610    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17611    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17612    @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; } }
17613    .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; }
17614    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17615    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17616    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17617    .theme-toggle .icon-sun { display:none; }
17618    body.dark-theme .theme-toggle .icon-sun { display:block; }
17619    body.dark-theme .theme-toggle .icon-moon { display:none; }
17620    .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;}
17621    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17622    .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);}
17623    .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;}
17624    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17625    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17626    .settings-modal-body{padding:14px 16px 16px;}
17627    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17628    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17629    .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;}
17630    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17631    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17632    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17633    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17634    .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;}
17635    .tz-select:focus{border-color:var(--oxide);}
17636    .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; }
17637    .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;}
17638    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
17639    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
17640    .hero, .panel { padding: 22px; }
17641    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
17642    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17643    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
17644    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
17645    .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; }
17646    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
17647    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
17648    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
17649    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
17650    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
17651    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
17652    .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; }
17653    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
17654    .delta-card-val { font-size:16px; font-weight:800; }
17655    .delta-card-val.pos { color:#1e7e34; }
17656    .delta-card-val.neg { color:var(--neg); }
17657    .delta-card-val.mod { color:#b35428; }
17658    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
17659    .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; }
17660    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17661    .delta-card-inline:hover .delta-card-tip { opacity:1; }
17662    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
17663    .compare-ts { font-size:13px; color:var(--muted); }
17664    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
17665    .compare-arrow { color: var(--muted); }
17666    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
17667    .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; }
17668    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
17669    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
17670    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
17671    .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; }
17672    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
17673    .run-mgmt-card .action-buttons { justify-content:center; }
17674    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
17675    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
17676    .button, .copy-button {
17677      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;
17678    }
17679    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
17680    @keyframes spin { to { transform: rotate(360deg); } }
17681    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
17682    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
17683    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
17684    .path-item strong { display: block; margin-bottom: 6px; }
17685    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
17686    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
17687    .path-subitem { flex: 1; }
17688    .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); }
17689    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); }
17690    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
17691    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
17692    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
17693    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
17694    th { color: var(--muted); font-weight: 700; }
17695    tr:last-child td { border-bottom: none; }
17696    #subm-tbl col:nth-child(1){width:15%;}
17697    #subm-tbl col:nth-child(2){width:31%;}
17698    #subm-tbl col:nth-child(3){width:9%;}
17699    #subm-tbl col:nth-child(4){width:9%;}
17700    #subm-tbl col:nth-child(5){width:9%;}
17701    #subm-tbl col:nth-child(6){width:9%;}
17702    #subm-tbl col:nth-child(7){width:9%;}
17703    #subm-tbl col:nth-child(8){width:9%;}
17704    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
17705    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
17706    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
17707    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
17708    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
17709    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
17710    .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; }
17711    .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; }
17712    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
17713    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
17714    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
17715    .muted { color: var(--muted); }
17716    /* Run-ID chip row (mirrors HTML report) */
17717    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
17718    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
17719    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
17720    .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; }
17721    .run-id-chip[data-copy] { cursor:pointer; }
17722    a.run-id-chip { text-decoration:none; cursor:pointer; }
17723    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
17724    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
17725    .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; }
17726    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
17727    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17728    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
17729    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
17730    a.commit-link-value { color:inherit; text-decoration:none; }
17731    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
17732    .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; }
17733    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17734    .run-id-chip:hover .chip-tooltip { opacity:1; }
17735    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
17736    .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; }
17737    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
17738    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
17739    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
17740    /* Meta chips row */
17741    .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%; }
17742    .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; }
17743    .meta-chip:last-child { border-right:none; }
17744    .meta-chip b { color:var(--text); font-weight:700; }
17745    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17746    .site-footer a{color:var(--muted);}
17747    .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; }
17748    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
17749    .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; }
17750    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
17751    /* Stat chips (matches HTML report) */
17752    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
17753    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
17754    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
17755    .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; }
17756    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
17757    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
17758    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
17759    .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; }
17760    .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; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:200; }
17761    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17762    .stat-chip:hover .stat-chip-tip { opacity:1; }
17763    /* Submodule panel */
17764    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
17765    /* Metrics tables stack */
17766    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
17767    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
17768    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
17769    .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)); }
17770    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
17771    /* Metrics table */
17772    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
17773    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
17774    .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; }
17775    .metrics-table thead th:not(:first-child) { text-align: right; }
17776    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
17777    .metrics-table tbody tr:last-child td { border-bottom: none; }
17778    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
17779    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
17780    .metrics-table tbody tr:hover td { background: var(--surface-2); }
17781    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
17782    .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; }
17783    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
17784    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
17785    .mt-val-pos { color: var(--pos); font-weight: 700; }
17786    .mt-val-neg { color: var(--neg); font-weight: 700; }
17787    .mt-val-zero { color: var(--muted); }
17788    .mt-val-mod { color: var(--oxide-2); }
17789    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
17790    @media (max-width: 1180px) {
17791      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
17792      .nav-project-slot, .nav-status { justify-content:flex-start; }
17793      .hero-top { flex-direction: column; }
17794      .run-mgmt-strip { flex-direction: column; }
17795    }
17796    .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;}
17797    @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));}}
17798    .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;}
17799    /* ── Result-page chart controls ─────────────────────────────────────────── */
17800    .r-chart-section{margin-bottom:24px;}
17801    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
17802    .section-pair > .panel{flex-shrink:0;}
17803    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
17804    .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;}
17805    .r-chart-select:focus{border-color:var(--accent);}
17806    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
17807    .r-chart-container svg{display:block;width:100%;height:auto;}
17808    .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;}
17809    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
17810    .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;}
17811    .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);}
17812    .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;}
17813    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
17814    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
17815    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
17816    .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;}
17817    .r-chart-modal-close:hover{opacity:.7;}
17818    body.dark-theme .r-chart-modal{background:var(--surface);}
17819    .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;}
17820    .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);}
17821    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
17822    .lang-bar-row:hover{transform:translateY(-2px);}
17823    .lang-bar-row .rchit:hover{filter:none;transform:none;}
17824    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
17825    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
17826    .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;}
17827    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
17828    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
17829    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
17830    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
17831    #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;}
17832    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
17833    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
17834    .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;}
17835    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
17836    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
17837    .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;}
17838    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
17839    .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;}
17840    .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;}
17841    body.has-report-banner .top-nav{top:27px;}
17842    body.has-report-banner{padding-bottom:27px;}
17843  </style>
17844</head>
17845<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
17846  <div class="background-watermarks" aria-hidden="true">
17847    <img src="/images/logo/logo-text.png" alt="" />
17848    <img src="/images/logo/logo-text.png" alt="" />
17849    <img src="/images/logo/logo-text.png" alt="" />
17850    <img src="/images/logo/logo-text.png" alt="" />
17851    <img src="/images/logo/logo-text.png" alt="" />
17852    <img src="/images/logo/logo-text.png" alt="" />
17853    <img src="/images/logo/logo-text.png" alt="" />
17854    <img src="/images/logo/logo-text.png" alt="" />
17855    <img src="/images/logo/logo-text.png" alt="" />
17856    <img src="/images/logo/logo-text.png" alt="" />
17857    <img src="/images/logo/logo-text.png" alt="" />
17858    <img src="/images/logo/logo-text.png" alt="" />
17859    <img src="/images/logo/logo-text.png" alt="" />
17860    <img src="/images/logo/logo-text.png" alt="" />
17861  </div>
17862  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17863  {% if let Some(banner) = report_header_footer %}
17864  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
17865  {% endif %}
17866  <div class="top-nav">
17867    <div class="top-nav-inner">
17868      <a class="brand" href="/">
17869        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17870        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17871      </a>
17872      <div class="nav-project-slot">
17873        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
17874      </div>
17875      <div class="nav-status">
17876        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
17877        <div class="nav-dropdown">
17878          <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>
17879          <div class="nav-dropdown-menu">
17880            <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>
17881          </div>
17882        </div>
17883        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
17884        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17885        <div class="nav-dropdown">
17886          <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>
17887          <div class="nav-dropdown-menu">
17888            <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>
17889          </div>
17890        </div>
17891        <div class="server-status-wrap" id="server-status-wrap">
17892          <div class="nav-pill server-online-pill" id="server-status-pill">
17893            <span class="status-dot" id="status-dot"></span>
17894            <span id="server-status-label">Server</span>
17895            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17896          </div>
17897          <div class="server-status-tip">
17898            OxideSLOC is running — accessible on your network.
17899            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17900          </div>
17901        </div>
17902        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17903          <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>
17904        </button>
17905        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17906          <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>
17907          <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>
17908        </button>
17909      </div>
17910    </div>
17911  </div>
17912
17913  <div class="page">
17914    <section class="hero">
17915      <div class="hero-top">
17916        <div>
17917          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
17918            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
17919            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
17920            <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>
17921          </div>
17922        </div>
17923        <div class="hero-quick-actions">
17924          {% if server_mode %}
17925          <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>
17926          {% else %}
17927          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
17928          {% endif %}
17929          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
17930          {% if !server_mode %}
17931          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
17932          {% endif %}
17933          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
17934          <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>
17935        </div>
17936      </div>
17937
17938      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
17939      <div class="run-id-row">
17940        <span class="run-id-chip" data-copy="{{ run_id }}">
17941          <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>
17942          <span class="run-id-chip-value">{{ run_id }}</span>
17943          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
17944        </span>
17945        {% match git_commit_long %}
17946          {% when Some with (long_sha) %}
17947          {% match git_commit_url %}
17948            {% when Some with (commit_url) %}
17949            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
17950              <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>
17951              <span class="run-id-chip-value">{{ long_sha }}</span>
17952              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
17953            </a>
17954            {% when None %}
17955            <span class="run-id-chip" data-copy="{{ long_sha }}">
17956              <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>
17957              <span class="run-id-chip-value">{{ long_sha }}</span>
17958              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
17959            </span>
17960          {% endmatch %}
17961          {% when None %}
17962          <span class="run-id-chip muted-chip">
17963            <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>
17964            <span class="run-id-chip-value">Not detected</span>
17965            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
17966          </span>
17967        {% endmatch %}
17968        {% match git_branch %}
17969          {% when Some with (branch) %}
17970          {% match git_branch_url %}
17971            {% when Some with (branch_url) %}
17972            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
17973              <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>
17974              <span class="run-id-chip-value">{{ branch }}</span>
17975              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
17976            </a>
17977            {% when None %}
17978            <span class="run-id-chip">
17979              <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>
17980              <span class="run-id-chip-value">{{ branch }}</span>
17981              <span class="chip-tooltip">Git branch active at scan time</span>
17982            </span>
17983          {% endmatch %}
17984          {% when None %}
17985          <span class="run-id-chip muted-chip">
17986            <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>
17987            <span class="run-id-chip-value">Not detected</span>
17988            <span class="chip-tooltip">No Git branch was found for this scan</span>
17989          </span>
17990        {% endmatch %}
17991        {% match git_author %}
17992          {% when Some with (author) %}
17993          <span class="run-id-chip" data-author="{{ author }}">
17994            <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>
17995            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
17996            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
17997          </span>
17998          {% when None %}
17999          <span class="run-id-chip muted-chip">
18000            <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>
18001            <span class="run-id-chip-value">Not detected</span>
18002            <span class="chip-tooltip">No commit author was found for this scan</span>
18003          </span>
18004        {% endmatch %}
18005      </div>
18006
18007      <!-- Scan metadata row -->
18008      <div class="meta">
18009        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
18010        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
18011        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
18012        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
18013        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
18014      </div>
18015
18016      <!-- 12 summary stat chips -->
18017      <div class="summary-strip">
18018        <div class="stat-chip" data-raw="{{ physical_lines }}">
18019          <div class="stat-chip-label">Physical lines</div>
18020          <div class="stat-chip-val">{{ physical_lines }}</div>
18021          <div class="stat-chip-exact"></div>
18022          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
18023        </div>
18024        <div class="stat-chip" data-raw="{{ code_lines }}">
18025          <div class="stat-chip-label">Code</div>
18026          <div class="stat-chip-val">{{ code_lines }}</div>
18027          <div class="stat-chip-exact"></div>
18028          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
18029        </div>
18030        <div class="stat-chip" data-raw="{{ comment_lines }}">
18031          <div class="stat-chip-label">Comments</div>
18032          <div class="stat-chip-val">{{ comment_lines }}</div>
18033          <div class="stat-chip-exact"></div>
18034          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
18035        </div>
18036        <div class="stat-chip" data-raw="{{ blank_lines }}">
18037          <div class="stat-chip-label">Blank</div>
18038          <div class="stat-chip-val">{{ blank_lines }}</div>
18039          <div class="stat-chip-exact"></div>
18040          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
18041        </div>
18042        <div class="stat-chip" data-raw="{{ mixed_lines }}">
18043          <div class="stat-chip-label">Mixed separate</div>
18044          <div class="stat-chip-val">{{ mixed_lines }}</div>
18045          <div class="stat-chip-exact"></div>
18046          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
18047        </div>
18048        <div class="stat-chip" data-raw="{{ functions }}">
18049          <div class="stat-chip-label">Functions</div>
18050          <div class="stat-chip-val">{{ functions }}</div>
18051          <div class="stat-chip-exact"></div>
18052          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
18053        </div>
18054        <div class="stat-chip" data-raw="{{ classes }}">
18055          <div class="stat-chip-label">Classes / Types</div>
18056          <div class="stat-chip-val">{{ classes }}</div>
18057          <div class="stat-chip-exact"></div>
18058          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
18059        </div>
18060        <div class="stat-chip" data-raw="{{ variables }}">
18061          <div class="stat-chip-label">Variables</div>
18062          <div class="stat-chip-val">{{ variables }}</div>
18063          <div class="stat-chip-exact"></div>
18064          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
18065        </div>
18066        <div class="stat-chip" data-raw="{{ imports }}">
18067          <div class="stat-chip-label">Imports</div>
18068          <div class="stat-chip-val">{{ imports }}</div>
18069          <div class="stat-chip-exact"></div>
18070          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
18071        </div>
18072        <div class="stat-chip" data-raw="{{ test_count }}">
18073          <div class="stat-chip-label">Tests</div>
18074          <div class="stat-chip-val">{{ test_count }}</div>
18075          <div class="stat-chip-exact"></div>
18076          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
18077        </div>
18078        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
18079          <div class="stat-chip-label">Code density</div>
18080          <div class="stat-chip-val stat-chip-density-val">—</div>
18081          <div class="stat-chip-exact"></div>
18082          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
18083        </div>
18084        <div class="stat-chip" data-raw="{{ files_analyzed }}">
18085          <div class="stat-chip-label">Files analyzed</div>
18086          <div class="stat-chip-val">{{ files_analyzed }}</div>
18087          <div class="stat-chip-exact"></div>
18088          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
18089        </div>
18090      </div>
18091
18092      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
18093      <div class="compare-banner">
18094        <div class="compare-banner-body">
18095          <div class="compare-banner-meta">
18096            <span class="compare-label">Previous scan</span>
18097            <span class="compare-ts">{{ prev_ts }}</span>
18098            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
18099            {% if let Some(prev_code) = prev_run_code_lines %}
18100            <div class="compare-banner-stats" style="margin-top:4px;">
18101              <span>Code before: <strong>{{ prev_code }}</strong></span>
18102              <span class="compare-arrow">→</span>
18103              <span>Code now: <strong>{{ code_lines }}</strong></span>
18104              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
18105              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
18106            </div>
18107            {% endif %}
18108          </div>
18109          {% if delta_lines_added.is_some() %}
18110          <div class="delta-cards-inline">
18111            <div class="delta-card-inline">
18112              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
18113              <div class="delta-card-lbl">lines added</div>
18114              <div class="delta-card-tip">Code lines added since the previous scan</div>
18115            </div>
18116            <div class="delta-card-inline">
18117              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
18118              <div class="delta-card-lbl">lines removed</div>
18119              <div class="delta-card-tip">Code lines removed since the previous scan</div>
18120            </div>
18121            <div class="delta-card-inline">
18122              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
18123              <div class="delta-card-lbl">unmodified lines</div>
18124              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
18125            </div>
18126            <div class="delta-card-inline">
18127              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
18128              <div class="delta-card-lbl">files modified</div>
18129              <div class="delta-card-tip">Files with at least one line changed</div>
18130            </div>
18131            <div class="delta-card-inline">
18132              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
18133              <div class="delta-card-lbl">files added</div>
18134              <div class="delta-card-tip">New files added since the previous scan</div>
18135            </div>
18136            <div class="delta-card-inline">
18137              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
18138              <div class="delta-card-lbl">files removed</div>
18139              <div class="delta-card-tip">Files deleted since the previous scan</div>
18140            </div>
18141            <div class="delta-card-inline">
18142              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
18143              <div class="delta-card-lbl">files unchanged</div>
18144              <div class="delta-card-tip">Files with no changes since the previous scan</div>
18145            </div>
18146          </div>
18147          {% else %}
18148          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
18149            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
18150          </p>
18151          {% endif %}
18152          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
18153        </div>
18154      </div>
18155      {% endif %}{% endif %}
18156
18157      <div class="action-grid">
18158        <div class="action-card">
18159          <h3>HTML report</h3>
18160          <div class="action-buttons">
18161            {% match html_url %}
18162              {% when Some with (url) %}
18163                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
18164              {% when None %}{% endmatch %}
18165            {% match html_download_url %}
18166              {% when Some with (url) %}
18167                <a class="button secondary" href="{{ url }}">Download HTML</a>
18168              {% when None %}{% endmatch %}
18169            {% match html_path %}
18170              {% when Some with (_path) %}{% when None %}{% endmatch %}
18171            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
18172          </div>
18173        </div>
18174        <div class="action-card">
18175          <h3>PDF report</h3>
18176          <div class="action-buttons">
18177            {% match pdf_url %}
18178              {% when Some with (url) %}
18179                {% if pdf_generating %}
18180                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
18181                    <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>
18182                    Generating PDF…
18183                  </button>
18184                {% else %}
18185                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
18186                {% endif %}
18187              {% when None %}
18188                {% match html_url %}
18189                  {% when Some with (_hurl) %}
18190                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
18191                    <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>
18192                  {% when None %}
18193                    <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;">
18194                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
18195                    </p>
18196                {% endmatch %}
18197            {% endmatch %}
18198            {% match pdf_download_url %}
18199              {% when Some with (url) %}
18200                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
18201              {% when None %}{% endmatch %}
18202            {% match pdf_url %}
18203              {% when Some with (_) %}
18204                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
18205              {% when None %}{% endmatch %}
18206          </div>
18207        </div>
18208        <div class="action-card">
18209          <h3>JSON result</h3>
18210          <div class="action-buttons">
18211            {% match json_url %}
18212              {% when Some with (url) %}
18213                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
18214              {% when None %}{% endmatch %}
18215            {% match json_download_url %}
18216              {% when Some with (url) %}
18217                <a class="button secondary" href="{{ url }}">Download JSON</a>
18218              {% when None %}{% endmatch %}
18219            {% match json_path %}
18220              {% when Some with (_path) %}
18221                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
18222              {% when None %}
18223                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
18224              {% endmatch %}
18225          </div>
18226        </div>
18227        <div class="action-card">
18228          <h3>Scan config</h3>
18229          <div class="action-buttons">
18230            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
18231            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
18232            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
18233          </div>
18234        </div>
18235        {% if confluence_configured %}
18236        <div class="action-card" id="confluenceCard">
18237          <h3>Confluence</h3>
18238          <div class="action-buttons">
18239            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
18240            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
18241          </div>
18242          <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>
18243        </div>
18244        {% endif %}
18245      </div>
18246      {% if confluence_configured %}
18247      <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;">
18248        <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);">
18249          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
18250          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
18251          <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;">
18252          <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>
18253          <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;">
18254          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
18255          <div style="display:flex;gap:10px;justify-content:flex-end;">
18256            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
18257            <button class="button" id="confSubmitBtn" type="button">Post</button>
18258          </div>
18259        </div>
18260      </div>
18261      {% endif %}
18262      <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;">
18263        <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);">
18264          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
18265          <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>
18266          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
18267          <div style="display:flex;gap:18px;justify-content:flex-end;">
18268            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
18269            <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>
18270          </div>
18271        </div>
18272      </div>
18273      {% if !submodule_rows.is_empty() %}
18274      <div class="submodule-panel">
18275        <div class="toolbar-row">
18276          <div>
18277            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
18278            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
18279          </div>
18280          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
18281        </div>
18282        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
18283        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
18284          <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>
18285          <thead>
18286            <tr>
18287              <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>
18288              <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>
18289              <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>
18290              <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>
18291              <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>
18292              <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>
18293              <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>
18294              <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>
18295            </tr>
18296          </thead>
18297          <tbody>
18298            {% for row in submodule_rows %}
18299            <tr>
18300              <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>
18301              <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>
18302              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
18303              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
18304              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
18305              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
18306              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
18307              <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>
18308            </tr>
18309            {% endfor %}
18310          </tbody>
18311        </table>
18312        </div>
18313      </div>
18314      {% endif %}
18315
18316      <div class="metrics-tables-stack">
18317
18318        <div class="metrics-table-wrap">
18319          <div class="metrics-table-title">Files</div>
18320          <table class="metrics-table">
18321            <thead>
18322              <tr>
18323                <th>Metric</th>
18324                <th>This Run</th>
18325                <th>Previous</th>
18326                <th>Change</th>
18327              </tr>
18328            </thead>
18329            <tbody>
18330              <tr>
18331                <td>Files analyzed</td>
18332                <td class="mt-val-large">{{ files_analyzed }}</td>
18333                <td>{{ prev_fa_str }}</td>
18334                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
18335              </tr>
18336              <tr>
18337                <td>Files skipped</td>
18338                <td>{{ files_skipped }}</td>
18339                <td>{{ prev_fs_str }}</td>
18340                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
18341              </tr>
18342              <tr>
18343                <td>Files modified</td>
18344                <td class="mt-val-na">—</td>
18345                <td class="mt-val-na">—</td>
18346                <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>
18347              </tr>
18348              <tr>
18349                <td>Files unchanged</td>
18350                <td class="mt-val-na">—</td>
18351                <td class="mt-val-na">—</td>
18352                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
18353              </tr>
18354            </tbody>
18355          </table>
18356        </div>
18357
18358        <div class="metrics-table-wrap">
18359          <div class="metrics-table-title">Line Counts</div>
18360          <table class="metrics-table">
18361            <thead>
18362              <tr>
18363                <th>Metric</th>
18364                <th>This Run</th>
18365                <th>Previous</th>
18366                <th>Change</th>
18367              </tr>
18368            </thead>
18369            <tbody>
18370              <tr>
18371                <td>Physical lines</td>
18372                <td class="mt-val-large">{{ physical_lines }}</td>
18373                <td>{{ prev_pl_str }}</td>
18374                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
18375              </tr>
18376              <tr>
18377                <td>Code lines</td>
18378                <td class="mt-val-large">{{ code_lines }}</td>
18379                <td>{{ prev_cl_str }}</td>
18380                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
18381              </tr>
18382              <tr>
18383                <td>Comment lines</td>
18384                <td>{{ comment_lines }}</td>
18385                <td>{{ prev_cml_str }}</td>
18386                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
18387              </tr>
18388              <tr>
18389                <td>Blank lines</td>
18390                <td>{{ blank_lines }}</td>
18391                <td>{{ prev_bl_str }}</td>
18392                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
18393              </tr>
18394              <tr>
18395                <td>Mixed (separate)</td>
18396                <td>{{ mixed_lines }}</td>
18397                <td class="mt-val-na">—</td>
18398                <td class="mt-val-na">—</td>
18399              </tr>
18400            </tbody>
18401          </table>
18402        </div>
18403
18404        <div class="metrics-tables-lower">
18405          <div class="metrics-table-wrap">
18406            <div class="metrics-table-title">Code Structure</div>
18407            <table class="metrics-table">
18408              <thead>
18409                <tr>
18410                  <th>Metric</th>
18411                  <th>This Run</th>
18412                </tr>
18413              </thead>
18414              <tbody>
18415                <tr>
18416                  <td>Functions</td>
18417                  <td>{{ functions }}</td>
18418                </tr>
18419                <tr>
18420                  <td>Classes / Types</td>
18421                  <td>{{ classes }}</td>
18422                </tr>
18423                <tr>
18424                  <td>Variables</td>
18425                  <td>{{ variables }}</td>
18426                </tr>
18427                <tr>
18428                  <td>Imports</td>
18429                  <td>{{ imports }}</td>
18430                </tr>
18431              </tbody>
18432            </table>
18433          </div>
18434
18435          <div class="metrics-table-wrap">
18436            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
18437            <table class="metrics-table">
18438              <thead>
18439                <tr>
18440                  <th>Metric</th>
18441                  <th>Change</th>
18442                </tr>
18443              </thead>
18444              <tbody>
18445                <tr>
18446                  <td>Lines added</td>
18447                  <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>
18448                </tr>
18449                <tr>
18450                  <td>Lines removed</td>
18451                  <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>
18452                </tr>
18453                <tr>
18454                  <td>Lines modified (net)</td>
18455                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
18456                </tr>
18457                <tr>
18458                  <td>Lines unmodified</td>
18459                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18460                </tr>
18461              </tbody>
18462            </table>
18463          </div>
18464        </div>
18465
18466      </div>
18467
18468      <div class="path-list">
18469        <div class="path-item">
18470          <div class="path-item-label">Project path</div>
18471          <code>{{ project_path }}</code>
18472        </div>
18473        <div class="path-item">
18474          <div class="path-item-label">Git branch</div>
18475          {% if let Some(branch) = git_branch %}
18476          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
18477          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
18478          {% else %}
18479          <code style="color:var(--muted)">—</code>
18480          {% endif %}
18481        </div>
18482        <div class="path-item">
18483          <div class="path-item-label">Output folder</div>
18484          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
18485        </div>
18486        <div class="path-item">
18487          <div class="path-item-label">Run ID</div>
18488          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
18489            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
18490            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
18491          </div>
18492        </div>
18493      </div>
18494    </section>
18495
18496    <div class="section-pair">
18497    <section class="panel">
18498        <div class="toolbar-row">
18499          <div>
18500            <h2>Language breakdown</h2>
18501            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
18502          </div>
18503          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18504        </div>
18505        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
18506    </section>
18507
18508    <section class="panel r-chart-section">
18509      <div class="toolbar-row" style="margin-bottom:16px;">
18510        <div>
18511          <h2>Visualizations</h2>
18512          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
18513        </div>
18514      </div>
18515
18516      <div class="r-viz-grid">
18517        <div class="r-viz-card">
18518          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18519            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
18520            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18521          </div>
18522          <div class="r-chart-tab-bar">
18523            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
18524            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
18525          </div>
18526          <div class="r-chart-container" id="r-composition-chart"></div>
18527        </div>
18528        <div class="r-viz-card">
18529          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18530            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
18531            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18532          </div>
18533          <div class="r-chart-container" id="r-scatter-chart"></div>
18534        </div>
18535        {% if has_semantic_data %}
18536        <div class="r-viz-card">
18537          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18538            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
18539            <select class="r-chart-select" id="r-semantic-metric">
18540              <option value="functions">Functions</option>
18541              <option value="classes">Classes</option>
18542              <option value="variables">Variables</option>
18543              <option value="imports">Imports</option>
18544              <option value="tests">Tests</option>
18545            </select>
18546            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18547          </div>
18548          <div class="r-chart-container" id="r-semantic-chart"></div>
18549        </div>
18550        {% endif %}
18551        <div class="r-viz-card">
18552          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18553            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
18554            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18555          </div>
18556          <div class="r-chart-container" id="r-density-chart"></div>
18557        </div>
18558        <div class="r-viz-card">
18559          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18560            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
18561            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18562          </div>
18563          <div class="r-chart-container" id="r-avglines-chart"></div>
18564        </div>
18565        <div class="r-viz-card">
18566          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
18567            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
18568            <select class="r-chart-select" id="r-sub-metric">
18569              <option value="code">Code Lines</option>
18570              <option value="comment">Comments</option>
18571              <option value="blank">Blank Lines</option>
18572              <option value="physical">Physical Lines</option>
18573              <option value="files">Files</option>
18574            </select>
18575            <select class="r-chart-select" id="r-sub-sort">
18576              <option value="desc">Value ↓</option>
18577              <option value="asc">Value ↑</option>
18578              <option value="name">Name A→Z</option>
18579            </select>
18580            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18581          </div>
18582          <div class="r-chart-container" id="r-submodule-chart"></div>
18583        </div>
18584      </div>
18585
18586    </section>
18587    </div>
18588
18589  </div>
18590
18591  <div id="r-tt" aria-hidden="true"></div>
18592
18593  <script nonce="{{ csp_nonce }}">
18594    (function () {
18595      var body = document.body;
18596      var themeToggle = document.getElementById('theme-toggle');
18597      var storageKey = 'oxide-sloc-theme';
18598
18599      function applyTheme(theme) {
18600        body.classList.toggle('dark-theme', theme === 'dark');
18601      }
18602
18603      function loadSavedTheme() {
18604        try {
18605          var saved = localStorage.getItem(storageKey);
18606          if (saved === 'dark' || saved === 'light') {
18607            applyTheme(saved);
18608          }
18609        } catch (e) {}
18610      }
18611
18612      if (themeToggle) {
18613        themeToggle.addEventListener('click', function () {
18614          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
18615          applyTheme(nextTheme);
18616          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
18617        });
18618      }
18619
18620      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
18621        button.addEventListener('click', function () {
18622          var value = button.getAttribute('data-copy-value') || '';
18623          if (!value) return;
18624          var originalText = button.textContent;
18625          function flashSuccess() {
18626            button.textContent = 'Copied!';
18627            setTimeout(function () { button.textContent = originalText; }, 1800);
18628          }
18629          function flashFail() {
18630            button.textContent = 'Copy failed';
18631            setTimeout(function () { button.textContent = originalText; }, 2000);
18632          }
18633          if (navigator.clipboard && navigator.clipboard.writeText) {
18634            navigator.clipboard.writeText(value).then(flashSuccess, function () {
18635              fallbackCopy(value, flashSuccess, flashFail);
18636            });
18637          } else {
18638            fallbackCopy(value, flashSuccess, flashFail);
18639          }
18640        });
18641      });
18642      function fallbackCopy(text, onSuccess, onFail) {
18643        try {
18644          var ta = document.createElement('textarea');
18645          ta.value = text;
18646          ta.style.position = 'fixed';
18647          ta.style.top = '-9999px';
18648          ta.style.left = '-9999px';
18649          document.body.appendChild(ta);
18650          ta.focus();
18651          ta.select();
18652          var ok = document.execCommand('copy');
18653          document.body.removeChild(ta);
18654          if (ok) { onSuccess(); } else { onFail(); }
18655        } catch (e) { onFail(); }
18656      }
18657
18658      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18659        btn.addEventListener('click', function () {
18660          var folder = btn.getAttribute('data-folder') || '';
18661          if (!folder) return;
18662          var orig = btn.textContent;
18663          fetch('/open-path?path=' + encodeURIComponent(folder))
18664            .then(function (r) { return r.json(); })
18665            .then(function (d) {
18666              if (d && d.server_mode_disabled) {
18667                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18668              } else if (d && d.ok) {
18669                btn.textContent = 'Opened!';
18670                setTimeout(function () { btn.textContent = orig; }, 1800);
18671              }
18672            })
18673            .catch(function () {
18674              btn.textContent = 'Failed';
18675              setTimeout(function () { btn.textContent = orig; }, 2000);
18676            });
18677        });
18678      });
18679
18680      loadSavedTheme();
18681
18682      // ── Compact number formatting for stat chips ──────────────────────────
18683      (function(){
18684        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();}
18685        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
18686          var raw=parseInt(chip.getAttribute('data-raw'),10);
18687          if(isNaN(raw))return;
18688          var valEl=chip.querySelector('.stat-chip-val');
18689          if(valEl)valEl.textContent=fmt(raw);
18690          var exactEl=chip.querySelector('.stat-chip-exact');
18691          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
18692        });
18693        // Code density chip
18694        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
18695          var code=parseInt(chip.getAttribute('data-code'),10);
18696          var phys=parseInt(chip.getAttribute('data-physical'),10);
18697          if(isNaN(code)||isNaN(phys)||phys===0)return;
18698          var pct=(code/phys*100).toFixed(1)+'%';
18699          var valEl=chip.querySelector('.stat-chip-val');
18700          if(valEl)valEl.textContent=pct;
18701        });
18702        // Populate author handle from data-author attribute
18703        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
18704          var author=chip.getAttribute('data-author');
18705          var el=chip.querySelector('.author-handle');
18706          if(el)el.textContent='/'+author.replace(/\s+/g,'');
18707        });
18708        // Click-to-copy on run-id-chip elements
18709        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
18710          chip.addEventListener('click',function(){
18711            var val=chip.getAttribute('data-copy');
18712            if(!val)return;
18713            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
18714            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);}
18715            chip.classList.add('chip-copied-flash');
18716            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
18717          });
18718        });
18719      })();
18720
18721      // ── Shared tooltip for all result-page charts ─────────────────────────
18722      var rTT=(function(){
18723        var el=document.getElementById('r-tt');
18724        if(!el)return{s:function(){},h:function(){},m:function(){}};
18725        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
18726        function hide(){el.style.display='none';}
18727        function move(e){
18728          var x=e.clientX+16,y=e.clientY-12;
18729          var r=el.getBoundingClientRect();
18730          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
18731          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
18732          el.style.left=x+'px';el.style.top=y+'px';
18733        }
18734        return{s:show,h:hide,m:move};
18735      })();
18736      window.rTT=rTT;
18737
18738      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
18739      (function(){
18740        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18741        document.addEventListener('mouseover',function(e){
18742          var t=e.target;
18743          while(t&&t.getAttribute){
18744            var l=t.getAttribute('data-ttl');
18745            if(l!==null){
18746              var v=t.getAttribute('data-ttv')||'';
18747              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
18748              return;
18749            }
18750            t=t.parentNode;
18751          }
18752        });
18753        document.addEventListener('mouseout',function(e){
18754          var t=e.target;
18755          while(t&&t.getAttribute){
18756            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
18757            t=t.parentNode;
18758          }
18759        });
18760        document.addEventListener('mousemove',function(e){
18761          var el=document.getElementById('r-tt');
18762          if(el&&el.style.display!=='none')rTT.m(e);
18763        });
18764        window.addEventListener('blur',function(){rTT.h();});
18765        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
18766      })();
18767
18768      // ── Language overview charts ───────────────────────────────────────────
18769      (function(){
18770        var D={{ lang_chart_json|safe }};
18771        if(!D||!D.length)return;
18772        var el=document.getElementById('result-lang-charts');
18773        if(!el)return;
18774        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18775        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
18776        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18777        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();}
18778        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18779        function px(n){return Math.round(n);}
18780        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+'"';}
18781        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
18782
18783        // Donut chart — height matches the stacked-bar chart so both panels align
18784        var rHb_d=28;
18785        var DH=Math.max(220,D.length*rHb_d+32);
18786        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
18787        var legX=204,DW=360;
18788        var legCount=D.length;
18789        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
18790        var legYStart=Math.round((DH-legCount*legSpacing)/2);
18791        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">';
18792        if(D.length===1){
18793          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
18794          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+'"/>';
18795        } else {
18796          var ang=-Math.PI/2;
18797          D.forEach(function(d,i){
18798            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18799            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
18800            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
18801            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
18802            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
18803            var pct=Math.round(d.code/tot*100);
18804            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"/>';
18805            ang+=sw;
18806          });
18807        }
18808        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
18809        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
18810        D.forEach(function(d,i){
18811          var ly=legYStart+i*legSpacing;
18812          var pctL=Math.round(d.code/tot*100);
18813          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
18814          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
18815          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
18816          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
18817          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
18818          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
18819          ds+='</g>';
18820        });
18821        ds+='</svg>';
18822
18823        // Horizontal stacked-bar chart — fills container width
18824        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
18825        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
18826        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">';
18827        D.forEach(function(d,i){
18828          var y=6+i*rHb,x=LW;
18829          var phys=d.physical||d.code+d.comments+d.blanks;
18830          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
18831          bs+='<g class="lang-bar-row">';
18832          bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
18833          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>';
18834          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;
18835          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;
18836          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"/>';
18837          bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
18838          bs+='</g>';
18839        });
18840        var ly=SH-14;
18841        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
18842        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
18843        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
18844        var totAll=totC+totCm+totBl||1;
18845        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
18846        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
18847        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
18848        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
18849        bs+='<g data-kind="code" style="cursor:pointer;">'
18850          +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
18851          +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
18852          +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
18853          +'</g>';
18854        bs+='<g data-kind="comment" style="cursor:pointer;">'
18855          +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
18856          +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
18857          +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
18858          +'</g>';
18859        bs+='<g data-kind="blank" style="cursor:pointer;">'
18860          +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
18861          +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
18862          +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
18863          +'</g>';
18864        bs+='</svg>';
18865        el.innerHTML='<div class="r-lang-overview">'+
18866          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
18867          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
18868        '</div>';
18869        function wireDonutLegend(svg){
18870          if(!svg)return;
18871          var paths=svg.querySelectorAll('path[data-lang]');
18872          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';}}}
18873          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
18874          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;}});
18875          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
18876        }
18877        function wireMixLegend(svg){
18878          if(!svg)return;
18879          var legGs=svg.querySelectorAll('g[data-kind]');
18880          var allRects=svg.querySelectorAll('rect[data-kind]');
18881          if(!legGs.length)return;
18882          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';}}
18883          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='';}}
18884          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]);}
18885        }
18886        wireDonutLegend(el.querySelector('svg'));
18887        wireMixLegend(el.querySelectorAll('svg')[1]);
18888
18889        // ── Language breakdown Full View expand ─────────────────────────────────
18890        var langOvBtn=document.getElementById('result-lang-overview-expand');
18891        if(langOvBtn){langOvBtn.addEventListener('click',function(){
18892          var src=document.getElementById('result-lang-charts');
18893          if(!src)return;
18894          var overlay=document.createElement('div');
18895          overlay.className='r-chart-modal-overlay';
18896          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>';
18897          document.body.appendChild(overlay);
18898          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18899          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18900          var wrap=document.getElementById('result-lang-overview-modal-wrap');
18901          if(wrap){
18902            wrap.innerHTML=src.innerHTML;
18903            var svgs=wrap.querySelectorAll('svg');
18904            for(var i=0;i<svgs.length;i++){
18905              svgs[i].removeAttribute('width');
18906              svgs[i].removeAttribute('height');
18907              svgs[i].style.cssText='display:block;width:100%;height:auto;';
18908            }
18909            var ov=wrap.querySelector('.r-lang-overview');
18910            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
18911            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
18912            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
18913            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
18914            wireDonutLegend(wrap.querySelector('svg'));
18915            wireMixLegend(wrap.querySelectorAll('svg')[1]);
18916            requestAnimationFrame(function(){
18917              var ss=wrap.querySelectorAll('svg');
18918              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%;';}}
18919            });
18920          }
18921        });}
18922      })();
18923
18924      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
18925      (function(){
18926        var LANG_D={{ lang_chart_json|safe }};
18927        var SCAT_D={{ scatter_chart_json|safe }};
18928        var SEM_D={{ semantic_chart_json|safe }};
18929        var SUB_D={{ submodule_chart_json|safe }};
18930        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
18931        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18932        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();}
18933        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18934        function px(n){return Math.round(n);}
18935        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+'"';}
18936
18937        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
18938        function renderCompositionInEl(el,mode,shOvr){
18939          if(!el||!LANG_D||!LANG_D.length)return;
18940          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18941          var LW=110,SH=shOvr||224;
18942          var svgW=Math.max(320,el.offsetWidth||480);
18943          var BW=Math.max(120,svgW-LW-80);
18944          var legendH=24,topPad=4;
18945          var n=LANG_D.length||1;
18946          var rowTotal=Math.floor((SH-legendH-topPad)/n);
18947          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18948          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">';
18949          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
18950          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
18951          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
18952          var totAll2=totC2+totCm2+totBl2||1;
18953          if(mode==='pct'){
18954            LANG_D.forEach(function(d,i){
18955              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
18956              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
18957              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18958              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>';
18959              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;
18960              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;
18961              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+'"/>';
18962              var pct=Math.round((d.code||0)/tot2*100);
18963              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>';
18964            });
18965          } else {
18966            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
18967            LANG_D.forEach(function(d,i){
18968              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
18969              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18970              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>';
18971              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;
18972              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;
18973              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+'"/>';
18974              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>';
18975            });
18976          }
18977          var ly=SH-legendH+4;
18978          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
18979          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
18980          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
18981          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
18982          s+='<g data-kind="code" style="cursor:pointer;">'
18983            +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
18984            +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
18985            +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
18986            +'</g>';
18987          s+='<g data-kind="comment" style="cursor:pointer;">'
18988            +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
18989            +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
18990            +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
18991            +'</g>';
18992          s+='<g data-kind="blank" style="cursor:pointer;">'
18993            +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
18994            +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
18995            +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
18996            +'</g>';
18997          s+='</svg>';
18998          el.innerHTML=s;
18999          wireMixLegendEl(el);
19000        }
19001        function wireMixLegendEl(container){
19002          var svg=container&&container.querySelector('svg');
19003          if(!svg)return;
19004          var legGs=svg.querySelectorAll('g[data-kind]');
19005          var allRects=svg.querySelectorAll('rect[data-kind]');
19006          if(!legGs.length)return;
19007          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';}}
19008          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='';}}
19009          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]);}
19010        }
19011        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
19012        renderComposition('abs');
19013        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
19014          btn.addEventListener('click',function(){
19015            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
19016            btn.classList.add('active');
19017            renderComposition(btn.getAttribute('data-rcomp'));
19018          });
19019        });
19020
19021        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
19022        function renderScatterInEl(el,hOvr){
19023          if(!el||!SCAT_D||!SCAT_D.length)return;
19024          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
19025          var W=Math.max(320,el.offsetWidth||480);
19026          var cW=W-PL-PR,cH=H-PT-PB;
19027          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
19028          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
19029          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
19030          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">';
19031          [0,0.25,0.5,0.75,1].forEach(function(t){
19032            var y=PT+cH*(1-t);
19033            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
19034            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>';
19035          });
19036          [0,0.25,0.5,0.75,1].forEach(function(t){
19037            var x=PL+cW*t;
19038            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
19039            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>';
19040          });
19041          SCAT_D.forEach(function(d,i){
19042            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
19043            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
19044            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"/>';
19045            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>';
19046          });
19047          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>';
19048          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>';
19049          s+='</svg>';
19050          el.innerHTML=s;
19051        }
19052        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19053
19054        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
19055        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
19056        // the old vertical column layout on wide containers.
19057        function renderSemanticInEl(el,key,sh){
19058          if(!el||!SEM_D||!SEM_D.length)return;
19059          var n2=SEM_D.length||1;
19060          var LW=112,SH=sh||Math.max(180,n2*28+26);
19061          var svgW=Math.max(320,el.offsetWidth||480);
19062          var BW=Math.max(120,svgW-LW-80);
19063          var topPad=4,botPad=14;
19064          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
19065          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
19066          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
19067          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">';
19068          SEM_D.forEach(function(d,i){
19069            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
19070            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>';
19071            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"/>';
19072            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>';
19073          });
19074          s+='</svg>';
19075          el.innerHTML=s;
19076        }
19077        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
19078        var semSel=document.getElementById('r-semantic-metric');
19079        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
19080        var semExpand=document.getElementById('r-semantic-expand');
19081        if(semExpand){
19082          semExpand.addEventListener('click',function(){
19083            var key=semSel?semSel.value:'functions';
19084            var n=SEM_D.length||1;
19085            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
19086            var modalH=Math.min(Math.max(360,n*38+60),maxH);
19087            var overlay=document.createElement('div');
19088            overlay.className='r-chart-modal-overlay';
19089            var optHtml=
19090              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
19091              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
19092              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
19093              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
19094              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
19095            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>';
19096            document.body.appendChild(overlay);
19097            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
19098            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
19099            var modalEl=document.getElementById('r-sem-modal-chart');
19100            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
19101            var modalSel=document.getElementById('r-sem-modal-metric');
19102            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
19103          });
19104        }
19105
19106        // ── Expand buttons: re-render charts at large size inside modal ──────────
19107        (function(){
19108          function makeExpandModal(title,mH,subtitle,ctrlHtml){
19109            var overlay=document.createElement('div');
19110            overlay.className='r-chart-modal-overlay';
19111            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
19112            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
19113            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>';
19114            document.body.appendChild(overlay);
19115            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
19116            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
19117            return overlay.querySelector('.r-expand-modal-chart');
19118          }
19119          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
19120          var compExpandBtn=document.getElementById('r-composition-expand');
19121          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
19122            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
19123            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
19124            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
19125              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
19126            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
19127            if(wrap){
19128              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
19129              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
19130                btn.addEventListener('click',function(){
19131                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
19132                  btn.classList.add('active');
19133                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
19134                });
19135              });
19136            }
19137          });}
19138          var scatExpandBtn=document.getElementById('r-scatter-expand');
19139          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
19140            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
19141            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
19142          });}
19143          var densExpandBtn=document.getElementById('r-density-expand');
19144          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
19145            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
19146            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
19147            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
19148          });}
19149          var avgExpandBtn=document.getElementById('r-avglines-expand');
19150          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
19151            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
19152            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
19153            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
19154          });}
19155          var subExpandBtn=document.getElementById('r-submodule-expand');
19156          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
19157            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
19158            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
19159            var metCtrl=
19160              '<select class="r-chart-select" id="r-sub-modal-metric">'
19161              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
19162              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
19163              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
19164              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
19165              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
19166              +'</select>';
19167            var sortCtrl=
19168              '<select class="r-chart-select" id="r-sub-modal-sort">'
19169              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
19170              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
19171              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
19172              +'</select>';
19173            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
19174            if(wrap){
19175              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
19176              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
19177              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
19178              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
19179              if(mSub)mSub.addEventListener('change',reRenderSub);
19180              if(mSort)mSort.addEventListener('change',reRenderSub);
19181            }
19182          });}
19183        })();
19184
19185        // ── Comment Density: comments / (code + comments) per language ───────────
19186        function renderDensityInEl(el,shOvr){
19187          if(!el||!LANG_D||!LANG_D.length)return;
19188          var n=LANG_D.length||1;
19189          var LW=112,SH=shOvr||Math.max(180,n*28+26);
19190          var svgW=Math.max(320,el.offsetWidth||480);
19191          var BW=Math.max(120,svgW-LW-80);
19192          var topPad=4,botPad=26;
19193          var rowTotal=Math.floor((SH-topPad-botPad)/n);
19194          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
19195          var densities=LANG_D.map(function(d){
19196            var sig=(d.code||0)+(d.comments||0);
19197            return sig>0?(d.comments||0)/sig:0;
19198          });
19199          var maxDen=Math.max.apply(null,densities)||1;
19200          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">';
19201          LANG_D.forEach(function(d,i){
19202            var den=densities[i],bw=den/maxDen*BW;
19203            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
19204            var pct=Math.round(den*100);
19205            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>';
19206            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"/>';
19207            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19208            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>';
19209          });
19210          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>';
19211          s+='</svg>';
19212          el.innerHTML=s;
19213        }
19214        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
19215        renderDensity();
19216
19217        // ── Avg Lines per File: code / files per language ─────────────────────
19218        function renderAvgLinesInEl(el,shOvr){
19219          if(!el||!LANG_D||!LANG_D.length)return;
19220          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
19221          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
19222          var n=data.length||1;
19223          var LW=112,SH=shOvr||Math.max(180,n*28+26);
19224          var svgW=Math.max(320,el.offsetWidth||480);
19225          var BW=Math.max(120,svgW-LW-80);
19226          var topPad=4,botPad=26;
19227          var rowTotal=Math.floor((SH-topPad-botPad)/n);
19228          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
19229          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
19230          var maxAvg=Math.max.apply(null,avgs)||1;
19231          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">';
19232          data.forEach(function(d,i){
19233            var avg=avgs[i],bw=avg/maxAvg*BW;
19234            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
19235            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>';
19236            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"/>';
19237            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19238            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>';
19239          });
19240          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>';
19241          s+='</svg>';
19242          el.innerHTML=s;
19243        }
19244        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
19245        renderAvgLines();
19246
19247        // ── Repository Overview: overall row + per-submodule rows ────────────
19248        function renderSubmoduleInEl(el,key,sort,shOvr){
19249          if(!el)return;
19250          var overall={
19251            name:'Overall',
19252            code:{{ code_lines }},
19253            comment:{{ comment_lines }},
19254            blank:{{ blank_lines }},
19255            physical:{{ physical_lines }},
19256            files:{{ files_analyzed }},
19257            isOverall:true
19258          };
19259          var subs=SUB_D.slice();
19260          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
19261          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
19262          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
19263          var data=[overall].concat(subs);
19264          var rowH=32,bH=22,sepH=subs.length>0?14:0;
19265          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
19266          var svgW=Math.max(320,el.offsetWidth||480);
19267          var LW=116,BW=Math.max(200,svgW-LW-54);
19268          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
19269          var OVERALL_COL='#6b7280';
19270          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">';
19271          var yOff=4;
19272          data.forEach(function(d,i){
19273            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
19274            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
19275            var label=d.name||d.path||'?';
19276            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>';
19277            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
19278            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19279            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>';
19280            yOff+=rowH;
19281            if(d.isOverall&&subs.length>0){
19282              yOff+=sepH;
19283            }
19284          });
19285          s+='</svg>';
19286          el.innerHTML=s;
19287        }
19288        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
19289        var subSel=document.getElementById('r-sub-metric');
19290        var sortSel=document.getElementById('r-sub-sort');
19291        renderSubmodule('code','desc');
19292        if(subSel){
19293          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
19294          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
19295        }
19296
19297        // Equalise heights within each chart row: if one chart in a grid row is taller
19298        // than its neighbour, re-render the shorter one at the taller height so bars fill
19299        // the available vertical space instead of leaving a gap.
19300        function syncRowHeights(){
19301          var avgEl=document.getElementById('r-avglines-chart');
19302          var subEl=document.getElementById('r-submodule-chart');
19303          if(avgEl&&subEl){
19304            var avgSvg=avgEl.querySelector('svg');
19305            var subSvg=subEl.querySelector('svg');
19306            if(avgSvg&&subSvg){
19307              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
19308              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
19309              var key=subSel?subSel.value||'code':'code';
19310              var sort=sortSel?sortSel.value:'desc';
19311              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
19312              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
19313            }
19314          }
19315          var semEl=document.getElementById('r-semantic-chart');
19316          var denEl=document.getElementById('r-density-chart');
19317          if(semEl&&denEl){
19318            var semSvg=semEl.querySelector('svg');
19319            var denSvg=denEl.querySelector('svg');
19320            if(semSvg&&denSvg){
19321              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
19322              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
19323              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
19324              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
19325            }
19326          }
19327        }
19328        syncRowHeights();
19329
19330        // Re-render all SVG charts when the window is resized so bars fill the card.
19331        var _rResizeTimer;
19332        window.addEventListener('resize',function(){
19333          clearTimeout(_rResizeTimer);
19334          _rResizeTimer=setTimeout(function(){
19335            var rcompBtn=document.querySelector('[data-rcomp].active');
19336            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
19337            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19338            if(semSel)renderSemantic(semSel.value||'functions');
19339            renderDensity();
19340            renderAvgLines();
19341            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
19342            syncRowHeights();
19343          },120);
19344        });
19345      })();
19346
19347      (function randomizeWatermarks() {
19348        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
19349        if (!wms.length) return;
19350        var placed = [];
19351        function tooClose(top, left) {
19352          for (var i = 0; i < placed.length; i++) {
19353            var dt = Math.abs(placed[i][0] - top);
19354            var dl = Math.abs(placed[i][1] - left);
19355            if (dt < 20 && dl < 18) return true;
19356          }
19357          return false;
19358        }
19359        function pick(leftBand) {
19360          for (var attempt = 0; attempt < 50; attempt++) {
19361            var top = Math.random() * 85 + 5;
19362            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19363            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19364          }
19365          var top = Math.random() * 85 + 5;
19366          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19367          placed.push([top, left]);
19368          return [top, left];
19369        }
19370        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
19371        var half = Math.floor(wms.length / 2);
19372        wms.forEach(function (img, i) {
19373          var pos = pick(i < half);
19374          var size = Math.floor(Math.random() * 100 + 160);
19375          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
19376          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
19377          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;
19378        });
19379      })();
19380
19381      (function spawnCodeParticles() {
19382        var container = document.getElementById('code-particles');
19383        if (!container) return;
19384        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'];
19385        for (var i = 0; i < 38; i++) {
19386          (function(idx) {
19387            var el = document.createElement('span');
19388            el.className = 'code-particle';
19389            el.textContent = snippets[idx % snippets.length];
19390            var left = Math.random() * 94 + 2;
19391            var top = Math.random() * 88 + 6;
19392            var dur = (Math.random() * 10 + 9).toFixed(1);
19393            var delay = (Math.random() * 18).toFixed(1);
19394            var rot = (Math.random() * 26 - 13).toFixed(1);
19395            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19396            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';
19397            container.appendChild(el);
19398          })(i);
19399        }
19400      })();
19401
19402      {% if pdf_generating %}
19403      // Poll for PDF readiness and swap the disabled button to a live link once done.
19404      (function() {
19405        var openBtn = document.getElementById('pdf-open-btn');
19406        var dlBtn = document.getElementById('pdf-download-btn');
19407        function checkPdf() {
19408          fetch('/api/runs/{{ run_id }}/pdf-status')
19409            .then(function(r) { return r.json(); })
19410            .then(function(d) {
19411              if (d.ready) {
19412                if (openBtn) {
19413                  var a = document.createElement('a');
19414                  a.className = 'button';
19415                  a.id = 'pdf-open-btn';
19416                  a.href = '/runs/pdf/{{ run_id }}';
19417                  a.target = '_blank';
19418                  a.rel = 'noopener';
19419                  a.textContent = 'Open PDF';
19420                  openBtn.replaceWith(a);
19421                }
19422                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
19423              } else {
19424                setTimeout(checkPdf, 3000);
19425              }
19426            })
19427            .catch(function() { setTimeout(checkPdf, 5000); });
19428        }
19429        setTimeout(checkPdf, 3000);
19430      })();
19431      {% endif %}
19432
19433    })();
19434  </script>
19435  <script nonce="{{ csp_nonce }}">
19436  (function(){
19437    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'}];
19438    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);});}
19439    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19440    function init(){
19441      var btn=document.getElementById('settings-btn');if(!btn)return;
19442      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19443      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>';
19444      document.body.appendChild(m);
19445      var g=document.getElementById('scheme-grid');
19446      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);});
19447      var cl=document.getElementById('settings-close');
19448      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);
19449      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');});
19450      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19451      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19452    }
19453    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19454  }());
19455  </script>
19456  <footer class="site-footer">
19457    local code analysis - metrics, history and reports
19458    &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>
19459    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19460    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19461    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19462    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19463  </footer>
19464  {% if confluence_configured %}
19465  <script nonce="{{ csp_nonce }}">
19466  (function() {
19467    var postBtn = document.getElementById('postConfluenceBtn');
19468    var copyBtn = document.getElementById('copyWikiBtn');
19469    var modal   = document.getElementById('confluenceModal');
19470    if (!postBtn || !modal) return;
19471
19472    postBtn.addEventListener('click', function() {
19473      document.getElementById('confStatus').style.display = 'none';
19474      modal.style.display = 'flex';
19475    });
19476    document.getElementById('confCancelBtn').addEventListener('click', function() {
19477      modal.style.display = 'none';
19478    });
19479    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19480
19481    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
19482      var btn = this;
19483      btn.disabled = true;
19484      var status = document.getElementById('confStatus');
19485      status.style.display = 'block';
19486      status.style.background = '#dbeafe';
19487      status.style.color = '#1e40af';
19488      status.textContent = 'Posting to Confluence…';
19489      var resp = await fetch('/api/confluence/post', {
19490        method: 'POST',
19491        headers: { 'Content-Type': 'application/json' },
19492        body: JSON.stringify({
19493          run_id: '{{ run_id }}',
19494          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
19495          report_url: document.getElementById('confReportUrl').value.trim() || null
19496        })
19497      });
19498      var data = await resp.json();
19499      if (data.ok) {
19500        status.style.background = '#dcfce7'; status.style.color = '#166534';
19501        status.textContent = 'Posted! Page ID: ' + data.page_id;
19502      } else {
19503        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19504        status.textContent = 'Error: ' + (data.error || 'Unknown error');
19505      }
19506      btn.disabled = false;
19507    });
19508
19509    if (copyBtn) {
19510      copyBtn.addEventListener('click', async function() {
19511        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
19512        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
19513        var text = await resp.text();
19514        try {
19515          await navigator.clipboard.writeText(text);
19516          var orig = copyBtn.textContent;
19517          copyBtn.textContent = 'Copied!';
19518          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
19519        } catch(e) {
19520          alert('Clipboard write failed — check browser permissions.');
19521        }
19522      });
19523    }
19524  })();
19525  </script>
19526  {% endif %}
19527  <script nonce="{{ csp_nonce }}">
19528  (function() {
19529    var deleteBtn = document.getElementById('delete-run-btn');
19530    var modal     = document.getElementById('delete-run-modal');
19531    var cancelBtn = document.getElementById('delete-run-cancel');
19532    var confirmBtn= document.getElementById('delete-run-confirm');
19533    if (!deleteBtn || !modal) return;
19534    deleteBtn.addEventListener('click', function() {
19535      document.getElementById('delete-run-status').style.display = 'none';
19536      modal.style.display = 'flex';
19537    });
19538    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
19539    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19540    confirmBtn.addEventListener('click', async function() {
19541      confirmBtn.disabled = true;
19542      cancelBtn.disabled = true;
19543      var status = document.getElementById('delete-run-status');
19544      status.style.display = 'block';
19545      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
19546      status.textContent = 'Deleting…';
19547      try {
19548        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
19549        if (resp.status === 204 || resp.ok) {
19550          status.style.background = '#dcfce7'; status.style.color = '#166534';
19551          status.textContent = 'Deleted. Redirecting…';
19552          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
19553        } else {
19554          var d = await resp.json().catch(function(){return {};});
19555          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19556          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
19557          confirmBtn.disabled = false;
19558          cancelBtn.disabled = false;
19559        }
19560      } catch (e) {
19561        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19562        status.textContent = 'Network error: ' + String(e);
19563        confirmBtn.disabled = false;
19564        cancelBtn.disabled = false;
19565      }
19566    });
19567  })();
19568  </script>
19569  <script nonce="{{ csp_nonce }}">(function(){
19570    var bundleBtn = document.getElementById('download-bundle-btn');
19571    if (bundleBtn) {
19572      bundleBtn.addEventListener('click', function() {
19573        bundleBtn.disabled = true;
19574        var orig = bundleBtn.textContent;
19575        bundleBtn.textContent = 'Preparing…';
19576        fetch('/api/runs/{{ run_id }}/bundle')
19577          .then(function(r) {
19578            if (!r.ok) throw new Error('HTTP ' + r.status);
19579            return r.blob();
19580          })
19581          .then(function(blob) {
19582            var url = URL.createObjectURL(blob);
19583            var a = document.createElement('a');
19584            a.href = url;
19585            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
19586            document.body.appendChild(a);
19587            a.click();
19588            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
19589            bundleBtn.disabled = false;
19590            bundleBtn.textContent = orig;
19591          })
19592          .catch(function(e) {
19593            bundleBtn.disabled = false;
19594            bundleBtn.textContent = orig;
19595            alert('Bundle download failed: ' + String(e));
19596          });
19597      });
19598    }
19599  })();</script>
19600  <script nonce="{{ csp_nonce }}">(function(){
19601    var dot=document.getElementById('status-dot');
19602    var pingEl=document.getElementById('server-ping-ms');
19603    var tipEl=document.getElementById('server-tip-ping');
19604    var fm=document.getElementById('footer-mode');
19605    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)';}}
19606    function doPing(){
19607      var t0=performance.now();
19608      fetch('/healthz',{cache:'no-store'})
19609        .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);})
19610        .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)';}});
19611    }
19612    doPing();
19613    setInterval(doPing,5000);
19614    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');}
19615  })();</script>
19616  {% if let Some(banner) = report_header_footer %}
19617  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
19618  {% endif %}
19619</body>
19620</html>
19621"##,
19622    ext = "html"
19623)]
19624// Template structs need many bool fields to pass Askama rendering flags.
19625#[allow(clippy::struct_excessive_bools)]
19626struct ResultTemplate {
19627    version: &'static str,
19628    report_title: String,
19629    project_path: String,
19630    output_dir: String,
19631    run_id: String,
19632    files_analyzed: u64,
19633    files_skipped: u64,
19634    physical_lines: u64,
19635    code_lines: u64,
19636    comment_lines: u64,
19637    blank_lines: u64,
19638    mixed_lines: u64,
19639    functions: u64,
19640    classes: u64,
19641    variables: u64,
19642    imports: u64,
19643    html_url: Option<String>,
19644    pdf_url: Option<String>,
19645    json_url: Option<String>,
19646    html_download_url: Option<String>,
19647    pdf_download_url: Option<String>,
19648    json_download_url: Option<String>,
19649    html_path: Option<String>,
19650    json_path: Option<String>,
19651    prev_run_id: Option<String>,
19652    prev_run_timestamp: Option<String>,
19653    prev_run_code_lines: Option<u64>,
19654    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
19655    prev_fa_str: String,
19656    prev_fs_str: String,
19657    prev_pl_str: String,
19658    prev_cl_str: String,
19659    prev_cml_str: String,
19660    prev_bl_str: String,
19661    // Signed change column for main metrics
19662    delta_fa_str: String,
19663    delta_fa_class: String,
19664    delta_fs_str: String,
19665    delta_fs_class: String,
19666    delta_pl_str: String,
19667    delta_pl_class: String,
19668    delta_cl_str: String,
19669    delta_cl_class: String,
19670    delta_cml_str: String,
19671    delta_cml_class: String,
19672    delta_bl_str: String,
19673    delta_bl_class: String,
19674    // delta vs previous scan
19675    delta_lines_added: Option<i64>,
19676    delta_lines_removed: Option<i64>,
19677    delta_lines_net_str: String,
19678    delta_lines_net_class: String,
19679    delta_files_added: Option<usize>,
19680    delta_files_removed: Option<usize>,
19681    delta_files_modified: Option<usize>,
19682    delta_files_unchanged: Option<usize>,
19683    delta_unmodified_lines: Option<u64>,
19684    // git context
19685    git_branch: Option<String>,
19686    git_branch_url: Option<String>,
19687    git_commit: Option<String>,
19688    git_commit_long: Option<String>,
19689    git_author: Option<String>,
19690    git_commit_url: Option<String>,
19691    // scan metadata for hero section
19692    scan_performed_by: String,
19693    scan_time_display: String,
19694    os_display: String,
19695    test_count: u64,
19696    // history
19697    prev_scan_count: usize,
19698    current_scan_number: usize,
19699    // submodule breakdown (empty when not requested)
19700    submodule_rows: Vec<SubmoduleRow>,
19701    scan_config_url: String,
19702    lang_chart_json: String,
19703    // Askama reads these via proc-macro expansion; clippy can't trace through it.
19704    #[allow(dead_code)]
19705    scatter_chart_json: String,
19706    #[allow(dead_code)]
19707    semantic_chart_json: String,
19708    #[allow(dead_code)]
19709    submodule_chart_json: String,
19710    #[allow(dead_code)]
19711    has_submodule_data: bool,
19712    #[allow(dead_code)]
19713    has_semantic_data: bool,
19714    pdf_generating: bool,
19715    csp_nonce: String,
19716    /// Whether Confluence integration is configured — shows Post button when true.
19717    confluence_configured: bool,
19718    server_mode: bool,
19719    /// Header/footer identification banner, mirrored from the HTML/PDF report.
19720    report_header_footer: Option<String>,
19721    run_id_short: String,
19722    /// True when rendering a static offline file (index.html); hides server-only actions.
19723    #[allow(dead_code)]
19724    is_offline: bool,
19725}
19726
19727#[derive(Template)]
19728#[template(
19729    source = r##"
19730<!doctype html>
19731<html lang="en">
19732<head>
19733  <meta charset="utf-8">
19734  <meta name="viewport" content="width=device-width, initial-scale=1">
19735  <title>OxideSLOC | Analyzing…</title>
19736  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19737  <style nonce="{{ csp_nonce }}">
19738    :root {
19739      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19740      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19741      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19742      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19743    }
19744    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19745    *{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;}
19746    .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);}
19747    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19748    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
19749    .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));}
19750    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19751    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19752    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19753    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19754    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19755    @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; } }
19756    .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;}
19757    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19758    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19759    .page-body{padding:32px 24px 36px;}
19760    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
19761    .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;}
19762    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
19763    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
19764    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
19765    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
19766    .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;}
19767    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
19768    .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;}
19769    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
19770    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
19771    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
19772    .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;}
19773    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
19774    .hidden{display:none!important;}
19775    .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;}
19776    .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;}
19777    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
19778    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
19779    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
19780    .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);}
19781    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
19782    .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;}
19783    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
19784    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19785    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19786    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
19787    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19788    .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;}
19789    @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));}}
19790    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19791    .site-footer a{color:var(--muted);}
19792    .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;}
19793    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
19794    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
19795    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
19796  </style>
19797</head>
19798<body>
19799  <div class="background-watermarks" aria-hidden="true">
19800    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19801    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19802    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19803    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19804    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19805    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19806  </div>
19807  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19808  <nav class="top-nav">
19809    <div class="top-nav-inner">
19810      <a href="/" class="brand">
19811        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
19812        <div class="brand-copy">
19813          <h1 class="brand-title">OxideSLOC</h1>
19814          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19815        </div>
19816      </a>
19817      <div class="nav-right">
19818        <a class="nav-pill" href="/">Home</a>
19819        <div class="nav-dropdown">
19820          <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>
19821          <div class="nav-dropdown-menu">
19822            <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>
19823          </div>
19824        </div>
19825        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19826        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19827        <div class="nav-dropdown">
19828          <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>
19829          <div class="nav-dropdown-menu">
19830            <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>
19831          </div>
19832        </div>
19833        <div class="server-status-wrap" id="server-status-wrap">
19834          <div class="nav-pill server-online-pill" id="server-status-pill">
19835            <span class="status-dot" id="status-dot"></span>
19836            <span id="server-status-label">Server</span>
19837            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19838          </div>
19839          <div class="server-status-tip">
19840            OxideSLOC is running — accessible on your network.
19841            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19842          </div>
19843        </div>
19844        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19845          <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>
19846        </button>
19847        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19848          <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>
19849          <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>
19850        </button>
19851      </div>
19852    </div>
19853  </nav>
19854  <div class="page-body">
19855    <div class="wait-panel">
19856      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
19857      <h2 class="wait-title">Analyzing your project…</h2>
19858      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19859      <div class="path-block">{{ project_path }}</div>
19860      <div class="metrics-row">
19861        <div class="metric-card">
19862          <div class="metric-label">Elapsed</div>
19863          <div class="metric-value" id="elapsed">0s</div>
19864        </div>
19865        <div class="metric-card">
19866          <div class="metric-label">Phase</div>
19867          <div class="metric-value" id="phase">Starting</div>
19868        </div>
19869        <div class="metric-card hidden" id="files-card">
19870          <div class="metric-label">Files</div>
19871          <div class="metric-value" id="files-progress">0</div>
19872        </div>
19873      </div>
19874      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
19875      <div class="warn-slow hidden" id="warn-slow">
19876        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.
19877      </div>
19878      <div class="err-panel hidden" id="err-panel">
19879        <strong>Analysis failed</strong>
19880        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
19881      </div>
19882      <div class="actions hidden" id="actions">
19883        <a href="/scan" class="btn-primary">Try Again</a>
19884        <a href="/view-reports" class="btn-outline">View Reports</a>
19885      </div>
19886    </div>
19887  </div>
19888  <script nonce="{{ csp_nonce }}">
19889    (function() {
19890      var WAIT_ID = {{ wait_id_json|safe }};
19891      var startTime = Date.now();
19892      var pollInterval = 1500;
19893      var retries = 0;
19894      var maxRetries = 5;
19895      var warnShown = false;
19896
19897      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();}
19898
19899      function elapsed() {
19900        return Math.floor((Date.now() - startTime) / 1000);
19901      }
19902
19903      function updateElapsed() {
19904        var s = elapsed();
19905        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
19906      }
19907
19908      function setPhase(txt) {
19909        document.getElementById('phase').textContent = txt;
19910      }
19911
19912      var elapsedTimer = setInterval(updateElapsed, 1000);
19913
19914      function poll() {
19915        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
19916          .then(function(r) {
19917            if (!r.ok) throw new Error('HTTP ' + r.status);
19918            return r.json();
19919          })
19920          .then(function(data) {
19921            retries = 0;
19922            if (data.state === 'complete') {
19923              clearInterval(elapsedTimer);
19924              setPhase('Done');
19925              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
19926            } else if (data.state === 'failed') {
19927              clearInterval(elapsedTimer);
19928              setPhase('Failed');
19929              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
19930              document.getElementById('err-panel').classList.remove('hidden');
19931              document.getElementById('actions').classList.remove('hidden');
19932            } else {
19933              // still running
19934              var s = elapsed();
19935              if (s > 90 && !warnShown) {
19936                warnShown = true;
19937                document.getElementById('warn-slow').classList.remove('hidden');
19938              }
19939              setPhase(data.phase || 'Running');
19940              var fd = data.files_done || 0, ft = data.files_total || 0;
19941              if (ft > 0) {
19942                var card = document.getElementById('files-card');
19943                if (card) card.classList.remove('hidden');
19944                var fp = document.getElementById('files-progress');
19945                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
19946              }
19947              setTimeout(poll, pollInterval);
19948            }
19949          })
19950          .catch(function(err) {
19951            retries++;
19952            if (retries >= maxRetries) {
19953              clearInterval(elapsedTimer);
19954              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
19955              document.getElementById('err-panel').classList.remove('hidden');
19956              document.getElementById('actions').classList.remove('hidden');
19957            } else {
19958              // exponential back-off capped at 8s
19959              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
19960            }
19961          });
19962      }
19963
19964      setTimeout(poll, pollInterval);
19965
19966      // If the browser restores this page from bfcache (Back after viewing results),
19967      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
19968      window.addEventListener("pageshow", function(e) {
19969        if (e.persisted) { setTimeout(poll, 200); }
19970      });
19971    })();
19972  </script>
19973  <footer class="site-footer">
19974    local code analysis - metrics, history and reports
19975    &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>
19976    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19977    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19978    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19979    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19980  </footer>
19981  <script nonce="{{ csp_nonce }}">
19982    (function(){
19983      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
19984      if(s==="dark")b.classList.add("dark-theme");
19985      var tt=document.getElementById("theme-toggle");
19986      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
19987    })();
19988    (function spawnCodeParticles(){
19989      var c=document.getElementById('code-particles');if(!c)return;
19990      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'];
19991      for(var i=0;i<32;i++){(function(idx){
19992        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
19993        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
19994        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
19995        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
19996        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
19997        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
19998        c.appendChild(el);
19999      })(i);}
20000    })();
20001    (function randomizeWatermarks(){
20002      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20003      var placed=[];
20004      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;}
20005      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];}
20006      var half=Math.floor(wms.length/2);
20007      wms.forEach(function(img,i){
20008        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
20009        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
20010        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
20011        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
20012        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
20013        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
20014      });
20015    })();
20016  </script>
20017  <script nonce="{{ csp_nonce }}">
20018  (function(){
20019    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'}];
20020    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);});}
20021    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20022    function init(){
20023      var btn=document.getElementById('settings-btn');if(!btn)return;
20024      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20025      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>';
20026      document.body.appendChild(m);
20027      var g=document.getElementById('scheme-grid');
20028      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);});
20029      var cl=document.getElementById('settings-close');
20030      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);
20031      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');});
20032      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20033      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20034    }
20035    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20036  }());
20037  </script>
20038  <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=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>
20039</body>
20040</html>
20041"##,
20042    ext = "html"
20043)]
20044struct ScanWaitTemplate {
20045    version: &'static str,
20046    wait_id_json: String,
20047    project_path: String,
20048    csp_nonce: String,
20049}
20050
20051#[derive(Template)]
20052#[template(
20053    source = r##"
20054<!doctype html>
20055<html lang="en">
20056<head>
20057  <meta charset="utf-8">
20058  <meta name="viewport" content="width=device-width, initial-scale=1">
20059  <title>OxideSLOC | Error</title>
20060  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20061  <style nonce="{{ csp_nonce }}">
20062    :root {
20063      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20064      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20065      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20066      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20067    }
20068    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20069    *{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;}
20070    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20071    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20072    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20073    .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);}
20074    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20075    .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));}
20076    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20077    .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;}
20078    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20079    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20080    @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; } }
20081    .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;}
20082    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20083    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20084    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20085    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20086    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20087    .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;}
20088    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20089    .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);}
20090    .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;}
20091    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20092    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20093    .settings-modal-body{padding:14px 16px 16px;}
20094    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20095    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20096    .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;}
20097    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20098    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20099    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20100    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20101    .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;}
20102    .tz-select:focus{border-color:var(--oxide);}
20103    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20104    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
20105    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20106    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20107    .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;}
20108    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20109    .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);}
20110    .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;}
20111    .btn-secondary:hover{background:var(--line);}
20112    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
20113    .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;}
20114    .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;}
20115    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
20116    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
20117    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
20118    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
20119    .bug-report-panel.open{display:flex;}
20120    .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;}
20121    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
20122    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
20123    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
20124    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
20125    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
20126    .br-network-badge.online .br-net-dot{background:#2a6846;}
20127    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
20128    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
20129    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
20130    .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;}
20131    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
20132    .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;}
20133    .btn-sm:hover{background:var(--line);}
20134    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
20135    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
20136    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
20137    .bug-report-hint a:hover{text-decoration:underline;}
20138    .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;}
20139    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20140    .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;}
20141    .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;}
20142    .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;}
20143    @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));}}
20144    .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;}
20145  </style>
20146</head>
20147<body>
20148  <div class="background-watermarks" aria-hidden="true">
20149    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20150    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20151    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20152    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20153    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20154    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20155  </div>
20156  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20157  <div class="top-nav">
20158    <div class="top-nav-inner">
20159      <a class="brand" href="/">
20160        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20161        <div class="brand-copy">
20162          <div class="brand-title">OxideSLOC</div>
20163          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20164        </div>
20165      </a>
20166      <div class="nav-right">
20167        <a class="nav-pill" href="/">Home</a>
20168        <div class="nav-dropdown">
20169          <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>
20170          <div class="nav-dropdown-menu">
20171            <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>
20172          </div>
20173        </div>
20174        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20175        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20176        <div class="nav-dropdown">
20177          <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>
20178          <div class="nav-dropdown-menu">
20179            <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>
20180          </div>
20181        </div>
20182        <div class="server-status-wrap" id="server-status-wrap">
20183          <div class="nav-pill server-online-pill" id="server-status-pill">
20184            <span class="status-dot" id="status-dot"></span>
20185            <span id="server-status-label">Server</span>
20186            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20187          </div>
20188          <div class="server-status-tip">
20189            OxideSLOC is running — accessible on your network.
20190            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20191          </div>
20192        </div>
20193        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20194          <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>
20195        </button>
20196        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20197          <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>
20198          <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>
20199        </button>
20200      </div>
20201    </div>
20202  </div>
20203
20204  <div class="page">
20205    <div class="panel">
20206      <h1>Error</h1>
20207      <div class="error-box" id="error-msg-text">{{ message }}</div>
20208      <div id="br-meta" hidden
20209        data-version="{{ version }}"
20210        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
20211        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
20212      <div class="actions">
20213        <a class="btn-primary" href="/scan">Back to setup</a>
20214        {% if let Some(report_url) = last_report_url %}
20215        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
20216        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
20217        {% else %}
20218        <a class="btn-secondary" href="/view-reports">View Reports</a>
20219        {% endif %}
20220      </div>
20221      <div class="bug-report-section" id="bug-report-section">
20222        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
20223          <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>
20224          Generate Bug Report
20225          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
20226        </button>
20227        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
20228          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
20229          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
20230          <div class="bug-report-btns">
20231            <button type="button" class="btn-sm" id="bug-report-copy">
20232              <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>
20233              Copy to clipboard
20234            </button>
20235            <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;">
20236              <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>
20237              Open GitHub Issue
20238            </a>
20239            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
20240              <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>
20241              Save as file
20242            </button>
20243          </div>
20244          <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>
20245          <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>
20246        </div>
20247      </div>
20248    </div>
20249  </div>
20250  <footer class="site-footer">
20251    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
20252    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20253    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20254    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20255    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20256  </footer>
20257  <script nonce="{{ csp_nonce }}">(function(){
20258    var meta=document.getElementById('br-meta');
20259    var pre=document.getElementById('bug-report-pre');
20260    var copyBtn=document.getElementById('bug-report-copy');
20261    var trigger=document.getElementById('bug-report-trigger');
20262    var panel=document.getElementById('bug-report-panel');
20263    var networkBadge=document.getElementById('br-network-badge');
20264    var networkLabel=document.getElementById('br-network-label');
20265    var ghLink=document.getElementById('bug-report-github-link');
20266    var saveBtn=document.getElementById('bug-report-save');
20267    var hintOnline=document.getElementById('br-hint-online');
20268    var hintOffline=document.getElementById('br-hint-offline');
20269    if(!meta||!pre)return;
20270    var ver=meta.getAttribute('data-version')||'';
20271    var runId=meta.getAttribute('data-run-id')||'';
20272    var code=meta.getAttribute('data-error-code')||'';
20273    var msgEl=document.getElementById('error-msg-text');
20274    var msg=msgEl?msgEl.textContent.trim():'';
20275    function getBrowser(){
20276      var ua=navigator.userAgent;
20277      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
20278      if(!m)return 'Unknown browser';
20279      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
20280      return n+' '+m[2];
20281    }
20282    var lines=['oxide-sloc Bug Report','==============================',''];
20283    lines.push('App version:  v'+ver);
20284    if(code)lines.push('HTTP status:  '+code);
20285    if(runId)lines.push('Run ID:       '+runId);
20286    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
20287    lines.push('Timestamp:    '+new Date().toISOString());
20288    lines.push('Browser:      '+getBrowser());
20289    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
20290    lines.push('');
20291    lines.push('Error message:');
20292    lines.push(msg);
20293    lines.push('');
20294    lines.push('Steps to reproduce:');
20295    lines.push('  1. ');
20296    lines.push('');
20297    lines.push('Expected behavior:');
20298    lines.push('  ');
20299    pre.textContent=lines.join('\n');
20300    function applyNetwork(online){
20301      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
20302      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
20303      if(ghLink){
20304        if(online){
20305          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
20306          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
20307        }
20308        ghLink.style.display=online?'inline-flex':'none';
20309      }
20310      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
20311      if(hintOnline)hintOnline.style.display=online?'block':'none';
20312      if(hintOffline)hintOffline.style.display=online?'none':'block';
20313    }
20314    applyNetwork(navigator.onLine);
20315    var probed=false;
20316    function probeNetwork(){
20317      if(probed)return;probed=true;
20318      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
20319      var probeIdx=0;
20320      function tryNext(){
20321        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
20322        var u=probeUrls[probeIdx++];
20323        var c2=new AbortController();
20324        var t2=setTimeout(function(){c2.abort();},4000);
20325        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
20326          .then(function(){clearTimeout(t2);applyNetwork(true);})
20327          .catch(function(){clearTimeout(t2);tryNext();});
20328      }
20329      tryNext();
20330    }
20331    if(trigger&&panel){
20332      trigger.addEventListener('click',function(){
20333        var open=panel.classList.toggle('open');
20334        trigger.classList.toggle('open',open);
20335        trigger.setAttribute('aria-expanded',open?'true':'false');
20336        if(open)probeNetwork();
20337      });
20338    }
20339    if(copyBtn){
20340      copyBtn.addEventListener('click',function(){
20341        var txt=pre.textContent;
20342        if(navigator.clipboard&&navigator.clipboard.writeText){
20343          navigator.clipboard.writeText(txt).then(function(){
20344            copyBtn.textContent='✓ Copied!';
20345            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);
20346          });
20347        }else{
20348          var ta=document.createElement('textarea');
20349          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
20350          document.body.appendChild(ta);ta.select();
20351          try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
20352          document.body.removeChild(ta);
20353        }
20354      });
20355    }
20356    if(saveBtn){
20357      saveBtn.addEventListener('click',function(){
20358        var txt=pre.textContent;
20359        var blob=new Blob([txt],{type:'text/plain'});
20360        var url=URL.createObjectURL(blob);
20361        var a=document.createElement('a');
20362        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
20363        document.body.appendChild(a);a.click();
20364        document.body.removeChild(a);URL.revokeObjectURL(url);
20365      });
20366    }
20367  })();</script>
20368  <script nonce="{{ csp_nonce }}">
20369    (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");});})();
20370    (function spawnCodeParticles() {
20371      var container = document.getElementById('code-particles');
20372      if (!container) return;
20373      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'];
20374      for (var i = 0; i < 38; i++) {
20375        (function(idx) {
20376          var el = document.createElement('span');
20377          el.className = 'code-particle';
20378          el.textContent = snippets[idx % snippets.length];
20379          var left = Math.random() * 94 + 2;
20380          var top = Math.random() * 88 + 6;
20381          var dur = (Math.random() * 10 + 9).toFixed(1);
20382          var delay = (Math.random() * 18).toFixed(1);
20383          var rot = (Math.random() * 26 - 13).toFixed(1);
20384          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20385          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';
20386          container.appendChild(el);
20387        })(i);
20388      }
20389    })();
20390    (function randomizeWatermarks() {
20391      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20392      var placed = [];
20393      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; }
20394      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]; }
20395      var half = Math.floor(wms.length/2);
20396      wms.forEach(function(img, i) {
20397        var pos = pick(i < half);
20398        var w = Math.floor(Math.random()*60+80);
20399        var rot = (Math.random()*40-20).toFixed(1);
20400        var op = (Math.random()*0.08+0.05).toFixed(2);
20401        var animDur = (Math.random()*6+5).toFixed(1);
20402        var animDelay = (Math.random()*10).toFixed(1);
20403        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';
20404      });
20405    })();
20406  </script>
20407  <script nonce="{{ csp_nonce }}">
20408  (function(){
20409    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'}];
20410    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);});}
20411    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20412    function init(){
20413      var btn=document.getElementById('settings-btn');if(!btn)return;
20414      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20415      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>';
20416      document.body.appendChild(m);
20417      var g=document.getElementById('scheme-grid');
20418      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);});
20419      var cl=document.getElementById('settings-close');
20420      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);
20421      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');});
20422      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20423      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20424    }
20425    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20426  }());
20427  </script>
20428  <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=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>
20429</body>
20430</html>
20431"##,
20432    ext = "html"
20433)]
20434struct ErrorTemplate {
20435    message: String,
20436    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
20437    last_report_url: Option<String>,
20438    /// Label for the secondary action button; defaults to "View last report" when None.
20439    last_report_label: Option<String>,
20440    /// Run ID to surface in the bug report; `None` when not applicable.
20441    run_id: Option<String>,
20442    /// HTTP status code to surface in the bug report; `None` when unknown.
20443    error_code: Option<u16>,
20444    csp_nonce: String,
20445    version: &'static str,
20446}
20447
20448// ── LocateFileTemplate ────────────────────────────────────────────────────────
20449
20450#[derive(Template)]
20451#[template(
20452    source = r##"
20453<!doctype html>
20454<html lang="en">
20455<head>
20456  <meta charset="utf-8">
20457  <meta name="viewport" content="width=device-width, initial-scale=1">
20458  <title>OxideSLOC | Locate Report</title>
20459  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20460  <style nonce="{{ csp_nonce }}">
20461    :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);}
20462    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
20463    *{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;}
20464    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20465    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20466    .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);}
20467    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20468    .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));}
20469    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20470    .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;}
20471    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20472    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20473    @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;}}
20474    .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;}
20475    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20476    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20477    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20478    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20479    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
20480    .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;}
20481    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20482    .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);}
20483    .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;}
20484    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20485    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20486    .settings-modal-body{padding:14px 16px 16px;}
20487    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20488    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20489    .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;}
20490    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20491    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20492    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20493    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20494    .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;}
20495    .tz-select:focus{border-color:var(--oxide);}
20496    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20497    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20498    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20499    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
20500    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
20501    .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;}
20502    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
20503    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20504    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20505    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20506    .locate-row{display:flex;gap:8px;align-items:stretch;}
20507    .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;}
20508    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20509    body.dark-theme .locate-input{background:var(--surface-2);}
20510    .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;}
20511    .warning-banner.show{display:flex;}
20512    .warning-banner svg{flex:0 0 auto;}
20513    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
20514    .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;}
20515    .error-inline.show{display:flex;}
20516    .error-inline svg{flex:0 0 auto;margin-top:2px;}
20517    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
20518    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
20519    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
20520    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
20521    .err-kv-p{margin:0 0 4px;}
20522    .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;}
20523    .success-inline.show{display:flex;}
20524    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20525    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
20526    .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;}
20527    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
20528    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
20529    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
20530    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
20531    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
20532    .fh-row:last-child{border-bottom:none;}
20533    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
20534    .fh-dir{font-weight:800;color:var(--text);}
20535    .fh-hl{color:var(--oxide);font-weight:700;}
20536    .fh-muted{color:var(--muted);}
20537    .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;}
20538    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
20539    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
20540    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
20541    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
20542    .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;}
20543    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
20544    .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;}
20545    .btn-secondary:hover{background:var(--line);}
20546    .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;}
20547    .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;}
20548    .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;}
20549    @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));}}
20550    .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;}
20551    .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;}
20552    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20553  </style>
20554</head>
20555<body>
20556  <div class="background-watermarks" aria-hidden="true">
20557    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20558    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20559    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20560    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20561    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20562    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20563  </div>
20564  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20565  <div class="top-nav">
20566    <div class="top-nav-inner">
20567      <a class="brand" href="/">
20568        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20569        <div class="brand-copy">
20570          <div class="brand-title">OxideSLOC</div>
20571          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20572        </div>
20573      </a>
20574      <div class="nav-right">
20575        <a class="nav-pill" href="/">Home</a>
20576        <div class="nav-dropdown">
20577          <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>
20578          <div class="nav-dropdown-menu">
20579            <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>
20580          </div>
20581        </div>
20582        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20583        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20584        <div class="nav-dropdown">
20585          <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>
20586          <div class="nav-dropdown-menu">
20587            <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>
20588          </div>
20589        </div>
20590        <div class="server-status-wrap" id="server-status-wrap">
20591          <div class="nav-pill server-online-pill" id="server-status-pill">
20592            <span class="status-dot" id="status-dot"></span>
20593            <span id="server-status-label">Server</span>
20594            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20595          </div>
20596          <div class="server-status-tip">
20597            OxideSLOC is running &mdash; accessible on your network.
20598            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20599          </div>
20600        </div>
20601        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20602          <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>
20603        </button>
20604        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20605          <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>
20606          <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>
20607        </button>
20608      </div>
20609    </div>
20610  </div>
20611
20612  <div class="page">
20613    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
20614    <div class="panel">
20615      <h1>Report File Not Found</h1>
20616      <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>
20617      <div class="field-label">Missing file</div>
20618      <div class="filename-chip">
20619        <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>
20620        {{ expected_filename }}
20621      </div>
20622      <div class="locate-section">
20623        <h2>Locate Scan Output Folder</h2>
20624        <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>
20625        <p>OxideSLOC will find the correct files inside automatically.</p>
20626        <div class="locate-row">
20627          <input type="text" id="locate-file-input"
20628                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
20629                 class="locate-input" autocomplete="off" spellcheck="false">
20630          {% if !server_mode %}
20631          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
20632          {% endif %}
20633        </div>
20634        <div class="warning-banner" id="filename-warning">
20635          <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>
20636          <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>
20637        </div>
20638        <div class="error-inline" id="locate-error">
20639          <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>
20640          <span id="locate-error-text"></span>
20641        </div>
20642        <div class="success-inline" id="locate-success">
20643          <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>
20644          <span>Scan restored &mdash; loading report&hellip;</span>
20645        </div>
20646        <div class="btn-row">
20647          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
20648          <a class="btn-secondary" href="/view-reports">View Reports</a>
20649        </div>
20650        <div class="folder-hint-shell">
20651          <div class="folder-hint-hdr">
20652            <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>
20653            Expected Folder Structure &mdash; Select the Top-Level Folder
20654          </div>
20655          <div class="folder-hint-body">
20656            <div class="fh-row">
20657              <span class="fh-tog">&#9658;</span>
20658              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
20659              <span class="fh-badge">&larr; select this</span>
20660            </div>
20661            <div class="fh-row fh-i1">
20662              <span class="fh-tog">&#9658;</span>
20663              <span class="fh-dir">html/</span>
20664            </div>
20665            <div class="fh-row fh-i2">
20666              <span class="fh-bul">&#8226;</span>
20667              <span class="fh-hl">{{ expected_filename }}</span>
20668            </div>
20669            <div class="fh-row fh-i1">
20670              <span class="fh-tog">&#9658;</span>
20671              <span class="fh-dir">json/</span>
20672            </div>
20673            <div class="fh-row fh-i2">
20674              <span class="fh-bul">&#8226;</span>
20675              <span class="fh-muted">result_*.json</span>
20676            </div>
20677            <div class="fh-row fh-i1">
20678              <span class="fh-tog">&#9658;</span>
20679              <span class="fh-dir">pdf/</span>
20680            </div>
20681            <div class="fh-row fh-i2">
20682              <span class="fh-bul">&#8226;</span>
20683              <span class="fh-muted">report_*.pdf</span>
20684            </div>
20685            <div class="fh-row fh-i1">
20686              <span class="fh-tog">&#9658;</span>
20687              <span class="fh-dir">excel/</span>
20688            </div>
20689            <div class="fh-row fh-i2">
20690              <span class="fh-bul">&#8226;</span>
20691              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
20692            </div>
20693          </div>
20694        </div>
20695      </div>
20696    </div>
20697  </div>
20698  <footer class="site-footer">
20699    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
20700    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20701    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20702    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20703    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20704  </footer>
20705  <script nonce="{{ csp_nonce }}">(function(){
20706    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
20707    if(s==="dark")b.classList.add("dark-theme");
20708    document.getElementById("theme-toggle").addEventListener("click",function(){
20709      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
20710    });
20711  })();</script>
20712  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
20713    var c=document.getElementById('code-particles');if(!c)return;
20714    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'];
20715    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);}
20716  })();
20717  (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>
20718  <script nonce="{{ csp_nonce }}">(function(){
20719    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'}];
20720    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);});}
20721    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20722    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');});}
20723    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20724  }());</script>
20725  <script nonce="{{ csp_nonce }}">(function(){
20726    var meta=document.getElementById('locate-meta');
20727    var inp=document.getElementById('locate-file-input');
20728    var browseBtn=document.getElementById('browse-locate-btn');
20729    var submitBtn=document.getElementById('locate-submit-btn');
20730    var warning=document.getElementById('filename-warning');
20731    var errBox=document.getElementById('locate-error');
20732    var errText=document.getElementById('locate-error-text');
20733    var okBox=document.getElementById('locate-success');
20734    var expected=meta?meta.getAttribute('data-expected'):'';
20735    var runId=meta?meta.getAttribute('data-run-id'):'';
20736    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
20737    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
20738    function showErr(msg){
20739      if(errText){
20740        errText.innerHTML='';
20741        var lines=msg.split('\n');
20742        var hasPairs=lines.some(function(l){return / : /.test(l);});
20743        if(!hasPairs){errText.textContent=msg;}
20744        else{
20745          var frag=document.createDocumentFragment();var tbl=null;
20746          lines.forEach(function(line){
20747            var m=line.match(/^(.*?) : (.*)$/);
20748            if(m){
20749              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
20750              var tr=document.createElement('tr');
20751              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
20752              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
20753              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
20754            } else {
20755              tbl=null;
20756              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
20757            }
20758          });
20759          errText.appendChild(frag);
20760        }
20761      }
20762      if(errBox)errBox.classList.add('show');
20763      if(okBox)okBox.classList.remove('show');
20764    }
20765    function clearErr(){
20766      if(errBox)errBox.classList.remove('show');
20767      if(okBox)okBox.classList.remove('show');
20768    }
20769    function validate(){
20770      var val=inp?inp.value.trim():'';
20771      clearErr();
20772      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
20773      if(submitBtn)submitBtn.disabled=false;
20774      if(warning){
20775        var name=basename(val);
20776        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
20777        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
20778        else warning.classList.remove('show');
20779      }
20780    }
20781    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
20782    if(browseBtn){
20783      browseBtn.addEventListener('click',function(){
20784        browseBtn.disabled=true;browseBtn.textContent='...';
20785        fetch('/pick-directory')
20786          .then(function(r){return r.ok?r.json():{cancelled:true};})
20787          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
20788          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20789      });
20790    }
20791    if(submitBtn){
20792      submitBtn.addEventListener('click',function(){
20793        var folder=inp?inp.value.trim():'';
20794        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
20795        clearErr();
20796        submitBtn.disabled=true;submitBtn.textContent='Restoring…';
20797        var body=new URLSearchParams();
20798        body.set('file_path',folder);
20799        body.set('redirect_url',redirectUrl);
20800        body.set('expected_run_id',runId);
20801        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20802          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
20803          .then(function(d){
20804            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20805            if(d&&d.ok){
20806              if(okBox)okBox.classList.add('show');
20807              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
20808            } else {
20809              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
20810            }
20811          })
20812          .catch(function(e){
20813            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20814            showErr('Network error: '+String(e));
20815          });
20816      });
20817    }
20818  })();</script>
20819  <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>
20820</body>
20821</html>
20822"##,
20823    ext = "html"
20824)]
20825struct LocateFileTemplate {
20826    run_id: String,
20827    artifact_type: String,
20828    expected_filename: String,
20829    server_mode: bool,
20830    csp_nonce: String,
20831    version: &'static str,
20832}
20833
20834// ── RelocateScanTemplate ──────────────────────────────────────────────────────
20835
20836#[derive(Template)]
20837#[template(
20838    source = r##"
20839<!doctype html>
20840<html lang="en">
20841<head>
20842  <meta charset="utf-8">
20843  <meta name="viewport" content="width=device-width, initial-scale=1">
20844  <title>OxideSLOC | Locate Scan Files</title>
20845  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20846  <style nonce="{{ csp_nonce }}">
20847    :root {
20848      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20849      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20850      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20851      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20852    }
20853    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20854    *{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;}
20855    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20856    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20857    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20858    .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);}
20859    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20860    .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));}
20861    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20862    .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;}
20863    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20864    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20865    @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;}}
20866    .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;}
20867    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20868    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20869    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20870    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20871    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20872    .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;}
20873    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20874    .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);}
20875    .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;}
20876    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20877    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20878    .settings-modal-body{padding:14px 16px 16px;}
20879    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20880    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20881    .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;}
20882    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20883    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20884    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20885    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20886    .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;}
20887    .tz-select:focus{border-color:var(--oxide);}
20888    .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20889    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20890    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20891    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
20892    .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;}
20893    .error-box.hidden{display:none;}
20894    .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;}
20895    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20896    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20897    .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;}
20898    .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;}
20899    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
20900    .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;}
20901    .btn-secondary:hover{background:var(--line);}
20902    .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;}
20903    .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;}
20904    .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;}
20905    @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));}}
20906    .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;}
20907    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20908    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20909    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20910    .relocate-row{display:flex;gap:8px;align-items:stretch;}
20911    .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;}
20912    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20913    body.dark-theme .relocate-input{background:var(--surface-2);}
20914  </style>
20915</head>
20916<body>
20917  <div class="background-watermarks" aria-hidden="true">
20918    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20919    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20920    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20921    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20922    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20923    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20924  </div>
20925  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20926  <div class="top-nav">
20927    <div class="top-nav-inner">
20928      <a class="brand" href="/">
20929        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20930        <div class="brand-copy">
20931          <div class="brand-title">OxideSLOC</div>
20932          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20933        </div>
20934      </a>
20935      <div class="nav-right">
20936        <a class="nav-pill" href="/">Home</a>
20937        <div class="nav-dropdown">
20938          <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>
20939          <div class="nav-dropdown-menu">
20940            <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>
20941          </div>
20942        </div>
20943        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
20944        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20945        <div class="nav-dropdown">
20946          <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>
20947          <div class="nav-dropdown-menu">
20948            <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>
20949          </div>
20950        </div>
20951        <div class="server-status-wrap" id="server-status-wrap">
20952          <div class="nav-pill server-online-pill" id="server-status-pill">
20953            <span class="status-dot" id="status-dot"></span>
20954            <span id="server-status-label">Server</span>
20955            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20956          </div>
20957          <div class="server-status-tip">
20958            OxideSLOC is running — accessible on your network.
20959            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20960          </div>
20961        </div>
20962        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20963          <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>
20964        </button>
20965        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20966          <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>
20967          <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>
20968        </button>
20969      </div>
20970    </div>
20971  </div>
20972
20973  <div class="page">
20974    <div class="panel">
20975      <h1>Scan Files Moved</h1>
20976      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
20977      <div class="error-box" id="relocate-error-box">{{ message }}</div>
20978      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
20979      <div class="relocate-section">
20980        <h2>Locate Scan Output</h2>
20981        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
20982        <div class="relocate-row">
20983          <input type="text" id="relocate-folder" name="folder_path"
20984                 value="{{ folder_hint }}"
20985                 placeholder="Path to folder containing scan output..."
20986                 class="relocate-input" autocomplete="off" spellcheck="false">
20987          {% if !server_mode %}
20988          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
20989          {% endif %}
20990        </div>
20991        <div style="margin-top:12px;">
20992          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
20993        </div>
20994      </div>
20995      <div class="actions">
20996        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
20997        <a class="btn-secondary" href="/view-reports">View Reports</a>
20998      </div>
20999    </div>
21000  </div>
21001  <footer class="site-footer">
21002    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
21003    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21004    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21005    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21006    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21007  </footer>
21008  <script nonce="{{ csp_nonce }}">
21009    (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");});})();
21010    (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);}})();
21011    (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;});})();
21012  </script>
21013  <script nonce="{{ csp_nonce }}">
21014  (function(){
21015    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'}];
21016    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);});}
21017    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21018    function init(){
21019      var btn=document.getElementById('settings-btn');if(!btn)return;
21020      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21021      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>';
21022      document.body.appendChild(m);
21023      var g=document.getElementById('scheme-grid');
21024      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);});
21025      var cl=document.getElementById('settings-close');
21026      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);
21027      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');});
21028      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21029      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21030    }
21031    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21032  }());
21033  (function(){
21034    var browseBtn=document.getElementById('browse-relocate-btn');
21035    if(browseBtn){
21036      browseBtn.addEventListener('click',function(){
21037        browseBtn.disabled=true;browseBtn.textContent='...';
21038        var inp=document.getElementById('relocate-folder');
21039        var hint=inp?inp.value:'';
21040        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
21041          .then(function(r){return r.ok?r.json():{cancelled:true};})
21042          .then(function(d){
21043            browseBtn.disabled=false;browseBtn.textContent='Browse…';
21044            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
21045          })
21046          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
21047      });
21048    }
21049    var restoreBtn=document.getElementById('restore-btn');
21050    var errBox=document.getElementById('relocate-error-box');
21051    var okBox=document.getElementById('relocate-success-box');
21052    if(restoreBtn){
21053      restoreBtn.addEventListener('click',function(){
21054        var inp=document.getElementById('relocate-folder');
21055        var folder=inp?inp.value.trim():'';
21056        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
21057        restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
21058        var body=new URLSearchParams();
21059        body.set('run_id','{{ run_id }}');
21060        body.set('redirect_url','{{ redirect_url }}');
21061        body.set('folder_path',folder);
21062        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
21063          .then(function(r){return r.json();})
21064          .then(function(d){
21065            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
21066            if(d&&d.ok){
21067              if(errBox)errBox.classList.add('hidden');
21068              if(okBox){okBox.style.display='block';}
21069              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
21070            } else {
21071              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
21072            }
21073          })
21074          .catch(function(e){
21075            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
21076            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
21077          });
21078      });
21079    }
21080  }());
21081  </script>
21082  <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=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>
21083</body>
21084</html>
21085"##,
21086    ext = "html"
21087)]
21088struct RelocateScanTemplate {
21089    message: String,
21090    run_id: String,
21091    folder_hint: String,
21092    redirect_url: String,
21093    server_mode: bool,
21094    csp_nonce: String,
21095    version: &'static str,
21096}
21097
21098// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
21099
21100#[derive(Template)]
21101#[template(
21102    source = r##"
21103<!doctype html>
21104<html lang="en">
21105<head>
21106  <meta charset="utf-8">
21107  <meta name="viewport" content="width=device-width, initial-scale=1">
21108  <title>OxideSLOC | View Reports</title>
21109  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21110  <style nonce="{{ csp_nonce }}">
21111    :root {
21112      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21113      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21114      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21115      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21116      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
21117    }
21118    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; }
21119    *{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;}
21120    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21121    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21122    .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);}
21123    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21124    .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));}
21125    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21126    .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;}
21127    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21128    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21129    @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; } }
21130    .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;}
21131    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21132    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21133    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21134    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21135    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21136    .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;}
21137    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21138    .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);}
21139    .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;}
21140    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21141    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21142    .settings-modal-body{padding:14px 16px 16px;}
21143    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21144    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21145    .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;}
21146    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21147    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21148    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21149    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21150    .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;}
21151    .tz-select:focus{border-color:var(--oxide);}
21152    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21153    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21154    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21155    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21156    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21157    .panel-meta{font-size:13px;color:var(--muted);}
21158    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21159    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21160    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21161    .per-page-label{font-size:13px;color:var(--muted);}
21162    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;}
21163    .filter-input{min-width:180px;cursor:text;}
21164    .table-wrap{width:100%;overflow-x:auto;}
21165    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
21166    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;}
21167    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21168    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21169    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21170    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21171    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21172    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21173    tr:last-child td{border-bottom:none;}
21174    tr:hover td{background:var(--surface-2);}
21175    .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);}
21176    .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);}
21177    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21178    .metric-num{font-weight:700;color:var(--text);}
21179    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21180    .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;}
21181    .btn:hover{background:var(--line);}
21182    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21183    .btn.primary:hover{opacity:.9;}
21184    .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;}
21185    .btn-back:hover{background:var(--line);}
21186    .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;}
21187    .export-btn:hover{background:var(--line);}
21188    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
21189    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
21190    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
21191    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21192    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21193    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21194    .pagination-info{font-size:13px;color:var(--muted);}
21195    .pagination-btns{display:flex;gap:6px;}
21196    .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;}
21197    .pg-btn:hover:not(:disabled){background:var(--line);}
21198    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21199    .pg-btn:disabled{opacity:.35;cursor:default;}
21200    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21201    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21202    .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;}
21203    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21204    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21205    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21206    .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);}
21207    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21208    .stat-chip:hover .stat-chip-tip{opacity:1;}
21209    .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;}
21210    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21211    .site-footer a{color:var(--muted);}
21212    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21213    .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%;}
21214    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
21215    .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;}
21216    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
21217    .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;}
21218    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
21219    .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;}
21220    .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;}
21221    .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;}
21222    @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));}}
21223    .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;}
21224    .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;}
21225    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21226    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21227    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21228    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21229    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21230    .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;}
21231    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21232    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21233    .watched-chip-rm:hover{color:var(--oxide);}
21234    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21235    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21236    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21237    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21238    .rpt-btn{min-width:58px;justify-content:center;}
21239    .flex-row{display:flex;align-items:center;gap:8px;}
21240    .report-cell{overflow:visible;white-space:normal;}
21241    #history-table col:nth-child(1){width:185px;}
21242    #history-table col:nth-child(2){width:220px;}
21243    #history-table col:nth-child(3){width:100px;}
21244    #history-table col:nth-child(4){width:72px;}
21245    #history-table col:nth-child(5){width:82px;}
21246    #history-table col:nth-child(6){width:82px;}
21247    #history-table col:nth-child(7){width:65px;}
21248    #history-table col:nth-child(8){width:90px;}
21249    #history-table col:nth-child(9){width:85px;}
21250    #history-table col:nth-child(10){width:115px;}
21251    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
21252    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
21253    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
21254    .submod-details summary::-webkit-details-marker{display:none;}
21255.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
21256    .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;}
21257    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
21258    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
21259  </style>
21260</head>
21261<body>
21262  <div class="background-watermarks" aria-hidden="true">
21263    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21264    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21265    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21266    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21267    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21268    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21269  </div>
21270  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21271  <div class="top-nav">
21272    <div class="top-nav-inner">
21273      <a class="brand" href="/">
21274        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21275        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
21276      </a>
21277      <div class="nav-right">
21278        <a class="nav-pill" href="/">Home</a>
21279        <div class="nav-dropdown">
21280          <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>
21281          <div class="nav-dropdown-menu">
21282            <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>
21283          </div>
21284        </div>
21285        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21286        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21287        <div class="nav-dropdown">
21288          <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>
21289          <div class="nav-dropdown-menu">
21290            <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>
21291          </div>
21292        </div>
21293        <div class="server-status-wrap" id="server-status-wrap">
21294          <div class="nav-pill server-online-pill" id="server-status-pill">
21295            <span class="status-dot" id="status-dot"></span>
21296            <span id="server-status-label">Server</span>
21297            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21298          </div>
21299          <div class="server-status-tip">
21300            OxideSLOC is running — accessible on your network.
21301            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21302          </div>
21303        </div>
21304        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21305          <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>
21306        </button>
21307        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21308          <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>
21309          <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>
21310        </button>
21311      </div>
21312    </div>
21313  </div>
21314
21315  <div class="page">
21316    {% if let Some(err) = browse_error %}
21317    <div class="toast-error">
21318      <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>
21319      {{ err }}
21320    </div>
21321    {% endif %}
21322    {% if linked_count > 0 %}
21323    <div class="toast-success">
21324      <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>
21325      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
21326    </div>
21327    {% endif %}
21328    <div class="watched-bar">
21329      <div class="watched-bar-left">
21330        <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>
21331        <span class="watched-label">Watched Folders</span>
21332        <div class="watched-chips">
21333          {% if server_mode %}
21334          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21335          {% else %}
21336          {% for dir in watched_dirs %}
21337          <span class="watched-chip">
21338            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21339            <form method="POST" action="/watched-dirs/remove" style="display:contents">
21340              <input type="hidden" name="folder_path" value="{{ dir }}">
21341              <input type="hidden" name="redirect_to" value="/view-reports">
21342              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
21343            </form>
21344          </span>
21345          {% endfor %}
21346          {% if watched_dirs.is_empty() %}
21347          <span class="watched-none">No folders watched — click Choose to add one</span>
21348          {% endif %}
21349          {% endif %}
21350        </div>
21351      </div>
21352      {% if !server_mode %}
21353      <div class="watched-bar-right">
21354        <button type="button" class="btn" id="add-watched-btn">
21355          <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>
21356          Choose
21357        </button>
21358        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21359          <input type="hidden" name="redirect_to" value="/view-reports">
21360          <button type="submit" class="btn">&#8635; Refresh</button>
21361        </form>
21362      </div>
21363      {% endif %}
21364    </div>
21365    {% if total_scans > 0 %}
21366    <div class="summary-strip">
21367      <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>
21368      <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>
21369      <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>
21370      <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>
21371    </div>
21372    {% endif %}
21373
21374    <section class="panel">
21375      <div class="panel-header">
21376        <div>
21377          <h1>View Reports</h1>
21378          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
21379          {% 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 %}
21380        </div>
21381        <div class="flex-row">
21382          <button type="button" class="export-btn" id="export-csv-btn">
21383            <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>
21384            Export CSV
21385          </button>
21386          <button type="button" class="export-btn" id="export-xls-btn">
21387            <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>
21388            Export Excel
21389          </button>
21390        </div>
21391      </div>
21392
21393      {% if entries.is_empty() %}
21394      <div class="empty-state">
21395        <strong>No reports with viewable HTML yet</strong>
21396        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.
21397      </div>
21398      {% else %}
21399      <div class="filter-row">
21400        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
21401        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21402        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
21403      </div>
21404      <div class="table-wrap">
21405        <table id="history-table">
21406          <colgroup>
21407            <col><col><col><col><col><col><col><col><col><col>
21408          </colgroup>
21409          <thead>
21410            <tr id="history-thead">
21411              <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>
21412              <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>
21413              <th>Run ID<div class="col-resize-handle"></div></th>
21414              <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>
21415              <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>
21416              <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>
21417              <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>
21418              <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>
21419              <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>
21420              <th>Report<div class="col-resize-handle"></div></th>
21421            </tr>
21422          </thead>
21423          <tbody id="history-tbody">
21424            {% for entry in entries %}
21425            <tr class="history-row" data-run="{{ entry.run_id }}"
21426                data-timestamp="{{ entry.timestamp }}"
21427                data-project="{{ entry.project_label }}"
21428                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
21429                data-skipped="{{ entry.files_skipped }}"
21430                data-comments="{{ entry.comment_lines }}"
21431                data-blank="{{ entry.blank_lines }}"
21432                data-branch="{{ entry.git_branch }}"
21433                data-commit="{{ entry.git_commit }}"
21434                data-html-url="/runs/html/{{ entry.run_id }}">
21435              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21436              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21437              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
21438              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
21439              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21440              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21441              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21442              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
21443              <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>
21444              <td class="report-cell">
21445                <div class="actions-cell">
21446                  {% 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 %}
21447                  {% 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 %}
21448                </div>
21449                {% if !entry.submodule_links.is_empty() %}
21450                <details class="submod-details">
21451                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
21452                  <div class="submod-link-list">
21453                    {% for sub in entry.submodule_links %}
21454                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
21455                    {% endfor %}
21456                  </div>
21457                </details>
21458                {% endif %}
21459              </td>
21460            </tr>
21461            {% endfor %}
21462          </tbody>
21463        </table>
21464      </div>
21465      <div class="pagination">
21466        <span class="pagination-info" id="pagination-info"></span>
21467        <div class="pagination-btns" id="pagination-btns"></div>
21468        <div class="flex-row">
21469          <span class="per-page-label">Show</span>
21470          <select class="per-page" id="per-page-sel">
21471            <option value="10">10 per page</option>
21472            <option value="25" selected>25 per page</option>
21473            <option value="50">50 per page</option>
21474            <option value="100">100 per page</option>
21475          </select>
21476          <span class="per-page-label" id="page-range-label"></span>
21477        </div>
21478      </div>
21479      {% endif %}
21480    </section>
21481  </div>
21482
21483  <footer class="site-footer">
21484    local code analysis - metrics, history and reports
21485    &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>
21486    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21487    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21488    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21489    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21490  </footer>
21491
21492  <script nonce="{{ csp_nonce }}">
21493    (function () {
21494      // ── Theme ──────────────────────────────────────────────────────────────
21495      var storageKey = 'oxide-sloc-theme';
21496      var body = document.body;
21497      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21498      var toggle = document.getElementById('theme-toggle');
21499      if (toggle) toggle.addEventListener('click', function () {
21500        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21501        body.classList.toggle('dark-theme', next === 'dark');
21502        try { localStorage.setItem(storageKey, next); } catch(e) {}
21503      });
21504
21505      // ── State ─────────────────────────────────────────────────────────────
21506      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
21507      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
21508      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21509
21510      // Aggregate stats from first (most recent) row
21511      if (allRows.length) {
21512        var first = allRows[0];
21513        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();}
21514        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>':'');}
21515        setChipVal('agg-code', first.dataset.code);
21516        setChipVal('agg-files', first.dataset.files);
21517        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
21518        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
21519      }
21520
21521      // ── Branch filter population ──────────────────────────────────────────
21522      (function() {
21523        var branches = {};
21524        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21525        var sel = document.getElementById('branch-filter');
21526        if (sel) Object.keys(branches).sort().forEach(function(b) {
21527          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21528        });
21529      })();
21530
21531      // ── Filter ────────────────────────────────────────────────────────────
21532      function getFilteredRows() {
21533        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21534        var branch = ((document.getElementById('branch-filter') || {}).value || '');
21535        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
21536          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21537          if (branch && (r.dataset.branch || '') !== branch) return false;
21538          return true;
21539        });
21540      }
21541
21542      // ── Pagination ────────────────────────────────────────────────────────
21543      function renderPage() {
21544        var filtered = getFilteredRows();
21545        var total = filtered.length;
21546        var totalPages = Math.max(1, Math.ceil(total / perPage));
21547        currentPage = Math.min(currentPage, totalPages);
21548        var start = (currentPage - 1) * perPage;
21549        var end = Math.min(start + perPage, total);
21550        var shown = {};
21551        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21552        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
21553          r.style.display = shown[r.dataset.run] ? '' : 'none';
21554        });
21555        var rl = document.getElementById('page-range-label');
21556        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21557        var info = document.getElementById('pagination-info');
21558        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21559        var btns = document.getElementById('pagination-btns');
21560        if (!btns) return;
21561        btns.innerHTML = '';
21562        function makeBtn(lbl, pg, active, disabled) {
21563          var b = document.createElement('button');
21564          b.className = 'pg-btn' + (active ? ' active' : '');
21565          b.textContent = lbl; b.disabled = disabled;
21566          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
21567          return b;
21568        }
21569        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
21570        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
21571        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
21572        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
21573      }
21574
21575      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
21576      window.applyFilters = function() { currentPage = 1; renderPage(); };
21577
21578      // ── Sorting ───────────────────────────────────────────────────────────
21579      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
21580      function doSort(col, type, order) {
21581        var tbody = document.getElementById('history-tbody');
21582        if (!tbody) return;
21583        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21584        rows.sort(function(a, b) {
21585          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
21586          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
21587          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
21588          return va < vb ? 1 : va > vb ? -1 : 0;
21589        });
21590        rows.forEach(function(r) { tbody.appendChild(r); });
21591        currentPage = 1; renderPage();
21592      }
21593      sortHeaders.forEach(function(th) {
21594        th.addEventListener('click', function(e) {
21595          if (e.target.classList.contains('col-resize-handle')) return;
21596          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
21597          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
21598          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21599          th.classList.add('sort-' + sortOrder);
21600          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
21601          doSort(col, type, sortOrder);
21602        });
21603      });
21604
21605      // ── Column resize ─────────────────────────────────────────────────────
21606      (function() {
21607        var table = document.getElementById('history-table');
21608        if (!table) return;
21609        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
21610        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
21611        ths.forEach(function(th, i) {
21612          var handle = th.querySelector('.col-resize-handle');
21613          if (!handle || !cols[i]) return;
21614          var startX, startW;
21615          handle.addEventListener('mousedown', function(e) {
21616            e.stopPropagation(); e.preventDefault();
21617            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
21618            handle.classList.add('dragging');
21619            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
21620            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
21621            document.addEventListener('mousemove', onMove);
21622            document.addEventListener('mouseup', onUp);
21623          });
21624        });
21625      })();
21626
21627      // ── Reset view ────────────────────────────────────────────────────────
21628      window.resetView = function() {
21629        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
21630        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
21631        sortCol = null; sortOrder = 'asc';
21632        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21633        var tbody = document.getElementById('history-tbody');
21634        if (tbody) {
21635          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21636          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
21637          rows.forEach(function(r) { tbody.appendChild(r); });
21638        }
21639        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
21640        var table = document.getElementById('history-table');
21641        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
21642        currentPage = 1; renderPage();
21643      };
21644
21645      renderPage();
21646
21647      // ── Export helpers ────────────────────────────────────────────────────
21648      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
21649      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
21650      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);}
21651      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;');}
21652      function slocXlsx(fname,sheet,hdrs,rows){
21653        var enc=new TextEncoder();
21654        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;}
21655        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;}
21656        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
21657        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
21658        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21659        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;}
21660        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];}
21661        var rx='<row r="1">';
21662        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
21663        rx+='</row>';
21664        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>';});
21665        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
21666        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>';
21667        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>';
21668        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>';
21669        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>',
21670          '_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>',
21671          '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>',
21672          '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>',
21673          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
21674        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'];
21675        var zparts=[],zcds=[],zoff=0,znf=0;
21676        order.forEach(function(name){
21677          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
21678          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]);
21679          var entry=new Uint8Array(lha.length+nb.length+sz);
21680          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
21681          zparts.push(entry);
21682          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));
21683          var cde=new Uint8Array(cda.length+nb.length);
21684          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
21685          zcds.push(cde);zoff+=entry.length;znf++;
21686        });
21687        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
21688        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]);
21689        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
21690        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
21691        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
21692        zout.set(new Uint8Array(ea),zpos);
21693        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
21694      }
21695
21696      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
21697      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;}
21698      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
21699      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
21700
21701      var csvBtn = document.getElementById('export-csv-btn');
21702      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
21703      var xlsBtn = document.getElementById('export-xls-btn');
21704      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
21705
21706      // ── Remaining CSP-safe event bindings ────────────────────────────────
21707      (function wireEvents() {
21708        var el;
21709        el = document.getElementById('reset-view-btn');
21710        if (el) el.addEventListener('click', window.resetView);
21711        el = document.getElementById('project-filter');
21712        if (el) el.addEventListener('input', window.applyFilters);
21713        el = document.getElementById('branch-filter');
21714        if (el) el.addEventListener('change', window.applyFilters);
21715        el = document.getElementById('per-page-sel');
21716        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
21717        el = document.getElementById('add-watched-btn');
21718        if (el) el.addEventListener('click', function() {
21719          fetch('/pick-directory?kind=reports')
21720            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
21721            .then(function(data) {
21722              if (!data.cancelled && data.selected_path) {
21723                var form = document.createElement('form');
21724                form.method = 'POST';
21725                form.action = '/watched-dirs/add';
21726                var ri = document.createElement('input');
21727                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
21728                var fi = document.createElement('input');
21729                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
21730                form.appendChild(ri); form.appendChild(fi);
21731                document.body.appendChild(form);
21732                form.submit();
21733              }
21734            })
21735            .catch(function(e) { alert('Could not open folder picker: ' + e); });
21736        });
21737      })();
21738
21739      (function randomizeWatermarks() {
21740        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21741        if (!wms.length) return;
21742        var placed = [];
21743        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;}
21744        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];}
21745        var half=Math.floor(wms.length/2);
21746        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;});
21747      })();
21748
21749      (function spawnCodeParticles() {
21750        var container = document.getElementById('code-particles');
21751        if (!container) return;
21752        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'];
21753        for (var i = 0; i < 38; i++) {
21754          (function(idx) {
21755            var el = document.createElement('span');
21756            el.className = 'code-particle';
21757            el.textContent = snippets[idx % snippets.length];
21758            var left = Math.random() * 94 + 2;
21759            var top = Math.random() * 88 + 6;
21760            var dur = (Math.random() * 10 + 9).toFixed(1);
21761            var delay = (Math.random() * 18).toFixed(1);
21762            var rot = (Math.random() * 26 - 13).toFixed(1);
21763            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21764            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';
21765            container.appendChild(el);
21766          })(i);
21767        }
21768      })();
21769    })();
21770  </script>
21771  <script nonce="{{ csp_nonce }}">
21772  (function(){
21773    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'}];
21774    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);});}
21775    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21776    function init(){
21777      var btn=document.getElementById('settings-btn');if(!btn)return;
21778      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21779      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>';
21780      document.body.appendChild(m);
21781      var g=document.getElementById('scheme-grid');
21782      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);});
21783      var cl=document.getElementById('settings-close');
21784      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);
21785      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');});
21786      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21787      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21788    }
21789    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21790  }());
21791  </script>
21792  <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>
21793</body>
21794</html>
21795"##,
21796    ext = "html"
21797)]
21798struct HistoryTemplate {
21799    version: &'static str,
21800    entries: Vec<HistoryEntryRow>,
21801    total_scans: usize,
21802    linked_count: usize,
21803    browse_error: Option<String>,
21804    watched_dirs: Vec<String>,
21805    csp_nonce: String,
21806    server_mode: bool,
21807}
21808
21809// ── CompareSelectTemplate ──────────────────────────────────────────────────────
21810
21811#[derive(Template)]
21812#[template(
21813    source = r##"
21814<!doctype html>
21815<html lang="en">
21816<head>
21817  <meta charset="utf-8">
21818  <meta name="viewport" content="width=device-width, initial-scale=1">
21819  <title>OxideSLOC | Compare Scans</title>
21820  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21821  <style nonce="{{ csp_nonce }}">
21822    :root {
21823      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21824      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21825      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21826      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21827      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
21828    }
21829    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
21830    *{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;}
21831    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21832    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21833    .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);}
21834    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21835    .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));}
21836    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21837    .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;}
21838    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21839    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21840    @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; } }
21841    .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;}
21842    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21843    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21844    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21845    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21846    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21847    .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;}
21848    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21849    .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);}
21850    .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;}
21851    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21852    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21853    .settings-modal-body{padding:14px 16px 16px;}
21854    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21855    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21856    .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;}
21857    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21858    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21859    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21860    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21861    .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;}
21862    .tz-select:focus{border-color:var(--oxide);}
21863    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21864    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21865    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21866    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21867    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21868    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
21869    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
21870    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21871    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21872    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21873    .per-page-label{font-size:13px;color:var(--muted);}
21874    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;}
21875    .filter-input{min-width:180px;cursor:text;}
21876    .table-wrap{width:100%;overflow-x:auto;}
21877    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
21878    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;}
21879    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21880    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21881    #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;}
21882    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
21883    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
21884    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
21885    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
21886    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
21887    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
21888    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
21889    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
21890    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
21891    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21892    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21893    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21894    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21895    tr:last-child td{border-bottom:none;}
21896    tr.selected td{background:var(--sel-bg);}
21897    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
21898    tr:hover:not(.selected) td{background:var(--surface-2);}
21899    tr{cursor:pointer;}
21900    .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);}
21901    .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);}
21902    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21903    .metric-num{font-weight:700;color:var(--text);}
21904    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21905    .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;}
21906    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
21907    .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;}
21908    .btn:hover{background:var(--line);}
21909    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
21910    .btn.primary:hover{opacity:.9;}
21911    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
21912    .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;}
21913    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21914    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21915    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21916    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21917    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21918    .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;}
21919    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21920    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21921    .watched-chip-rm:hover{color:var(--oxide);}
21922    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21923    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21924    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21925    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21926    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
21927    .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;}
21928    .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;}
21929    .btn-back:hover{background:var(--line);}
21930    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21931    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21932    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21933    .pagination-info{font-size:13px;color:var(--muted);}
21934    .pagination-btns{display:flex;gap:6px;}
21935    .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;}
21936    .pg-btn:hover:not(:disabled){background:var(--line);}
21937    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21938    .pg-btn:disabled{opacity:.35;cursor:default;}
21939    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
21940    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21941    .site-footer a{color:var(--muted);}
21942    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21943    .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;}
21944    .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;}
21945    .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;}
21946    @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));}}
21947    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21948    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21949    .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;}
21950    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21951    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21952    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21953    .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);}
21954    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21955    .stat-chip:hover .stat-chip-tip{opacity:1;}
21956    .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;}
21957    .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;}
21958    .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%;}
21959    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
21960    .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;}
21961    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
21962    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
21963    .hidden{display:none!important;}
21964    .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%;}
21965    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
21966    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
21967    .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;}
21968    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
21969    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
21970    .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;}
21971    .scope-option:hover{background:var(--line);}
21972    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
21973    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
21974    .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;}
21975    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
21976    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
21977    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
21978    .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;}
21979  </style>
21980</head>
21981<body>
21982  <div class="background-watermarks" aria-hidden="true">
21983    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21984    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21985    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21986    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21987    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21988    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21989  </div>
21990  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21991  <div class="top-nav">
21992    <div class="top-nav-inner">
21993      <a class="brand" href="/">
21994        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21995        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
21996      </a>
21997      <div class="nav-right">
21998        <a class="nav-pill" href="/">Home</a>
21999        <div class="nav-dropdown">
22000          <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>
22001          <div class="nav-dropdown-menu">
22002            <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>
22003          </div>
22004        </div>
22005        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22006        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22007        <div class="nav-dropdown">
22008          <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>
22009          <div class="nav-dropdown-menu">
22010            <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>
22011          </div>
22012        </div>
22013        <div class="server-status-wrap" id="server-status-wrap">
22014          <div class="nav-pill server-online-pill" id="server-status-pill">
22015            <span class="status-dot" id="status-dot"></span>
22016            <span id="server-status-label">Server</span>
22017            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22018          </div>
22019          <div class="server-status-tip">
22020            OxideSLOC is running — accessible on your network.
22021            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22022          </div>
22023        </div>
22024        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22025          <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>
22026        </button>
22027        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22028          <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>
22029          <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>
22030        </button>
22031      </div>
22032    </div>
22033  </div>
22034
22035  <div class="page">
22036    <div class="watched-bar">
22037      <div class="watched-bar-left">
22038        <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>
22039        <span class="watched-label">Watched Folders</span>
22040        <div class="watched-chips">
22041          {% if server_mode %}
22042          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
22043          {% else %}
22044          {% for dir in watched_dirs %}
22045          <span class="watched-chip">
22046            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
22047            <form method="POST" action="/watched-dirs/remove" style="display:contents">
22048              <input type="hidden" name="folder_path" value="{{ dir }}">
22049              <input type="hidden" name="redirect_to" value="/compare-scans">
22050              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
22051            </form>
22052          </span>
22053          {% endfor %}
22054          {% if watched_dirs.is_empty() %}
22055          <span class="watched-none">No folders watched — click Choose to add one</span>
22056          {% endif %}
22057          {% endif %}
22058        </div>
22059      </div>
22060      {% if !server_mode %}
22061      <div class="watched-bar-right">
22062        <button type="button" class="btn" id="add-watched-btn">
22063          <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>
22064          Choose
22065        </button>
22066        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
22067          <input type="hidden" name="redirect_to" value="/compare-scans">
22068          <button type="submit" class="btn">&#8635; Refresh</button>
22069        </form>
22070      </div>
22071      {% endif %}
22072    </div>
22073    {% if total_scans > 0 %}
22074    <div class="summary-strip">
22075      <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>
22076      <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>
22077      <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>
22078      <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>
22079    </div>
22080    {% endif %}
22081    <section class="panel">
22082      <div class="panel-header">
22083        <div>
22084          <h1>Compare Scans</h1>
22085          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
22086        </div>
22087        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
22088          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
22089            <button class="btn primary" id="compare-btn" disabled>
22090              <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>
22091              Compare <span class="sel-count" id="sel-count">0/2</span>
22092            </button>
22093          </div>
22094        </div>
22095      </div>
22096
22097      {% if entries.is_empty() %}
22098      <div class="empty-state">
22099        <strong>No scans yet</strong>
22100        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.
22101      </div>
22102      {% else %}
22103      <div class="filter-row">
22104        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
22105        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
22106        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
22107      </div>
22108      <div class="scope-panel hidden" id="scope-panel">
22109        <div class="scope-panel-label">
22110          <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>
22111          Compare scope — choose what to include
22112        </div>
22113        <div class="scope-options" id="scope-options"></div>
22114      </div>
22115      {% if total_scans > 0 %}
22116      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
22117        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
22118          <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>
22119          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
22120        </div>
22121      </div>
22122      {% endif %}
22123      <div class="table-wrap">
22124        <table id="compare-table">
22125          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
22126          <thead>
22127            <tr id="compare-thead">
22128              <th><div class="col-resize-handle"></div></th>
22129              <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>
22130              <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>
22131              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
22132              <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>
22133              <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>
22134              <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>
22135              <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>
22136              <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>
22137              <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>
22138              <th>Submodules<div class="col-resize-handle"></div></th>
22139            </tr>
22140          </thead>
22141          <tbody id="compare-tbody">
22142            {% for entry in entries %}
22143            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
22144                data-timestamp="{{ entry.timestamp }}"
22145                data-project="{{ entry.project_label }}"
22146                data-files="{{ entry.files_analyzed }}"
22147                data-code="{{ entry.code_lines }}"
22148                data-comments="{{ entry.comment_lines }}"
22149                data-blank="{{ entry.blank_lines }}"
22150                data-branch="{{ entry.git_branch }}"
22151                data-commit="{{ entry.git_commit }}"
22152                data-submodules="{{ entry.submodule_names_csv }}">
22153              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
22154              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
22155              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
22156              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
22157              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
22158              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
22159              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
22160              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
22161              <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>
22162              <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>
22163              <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>
22164            </tr>
22165            {% endfor %}
22166          </tbody>
22167        </table>
22168      </div>
22169      <div class="pagination">
22170        <span class="pagination-info" id="pagination-info"></span>
22171        <div class="pagination-btns" id="pagination-btns"></div>
22172        <div class="flex-row">
22173          <span class="per-page-label">Show</span>
22174          <select class="per-page" id="per-page-sel">
22175            <option value="10">10 per page</option>
22176            <option value="25" selected>25 per page</option>
22177            <option value="50">50 per page</option>
22178            <option value="100">100 per page</option>
22179          </select>
22180          <span class="per-page-label" id="page-range-label"></span>
22181        </div>
22182      </div>
22183      {% endif %}
22184    </section>
22185  </div>
22186
22187  <footer class="site-footer">
22188    local code analysis - metrics, history and reports
22189    &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>
22190    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22191    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22192    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22193    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22194  </footer>
22195
22196  <script nonce="{{ csp_nonce }}">
22197    (function () {
22198      // ── Theme ──────────────────────────────────────────────────────────────
22199      var storageKey = 'oxide-sloc-theme';
22200      var body = document.body;
22201      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22202      var toggle = document.getElementById('theme-toggle');
22203      if (toggle) toggle.addEventListener('click', function () {
22204        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22205        body.classList.toggle('dark-theme', next === 'dark');
22206        try { localStorage.setItem(storageKey, next); } catch(e) {}
22207      });
22208
22209      // ── State ─────────────────────────────────────────────────────────────
22210      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
22211      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
22212      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
22213
22214      // ── Stat chips ────────────────────────────────────────────────────────
22215      (function() {
22216        var projects = {}, latestTs = '', latestRow = null;
22217        allRows.forEach(function(r) {
22218          var p = r.dataset.project || ''; if (p) projects[p] = true;
22219          var ts = r.dataset.timestamp || '';
22220          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
22221        });
22222        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();}
22223        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>':'');}
22224        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
22225        if (latestRow) {
22226          setChipVal('agg-code', latestRow.dataset.code);
22227          setChipVal('agg-files', latestRow.dataset.files);
22228        }
22229      })();
22230
22231      // ── Branch filter population ──────────────────────────────────────────
22232      (function() {
22233        var branches = {};
22234        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
22235        var sel = document.getElementById('branch-filter');
22236        if (sel) Object.keys(branches).sort().forEach(function(b) {
22237          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
22238        });
22239      })();
22240
22241      // ── Filter ────────────────────────────────────────────────────────────
22242      function getFilteredRows() {
22243        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
22244        var branch = ((document.getElementById('branch-filter') || {}).value || '');
22245        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
22246          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
22247          if (branch && (r.dataset.branch || '') !== branch) return false;
22248          return true;
22249        });
22250      }
22251
22252      // ── Pagination ────────────────────────────────────────────────────────
22253      function renderPage() {
22254        var filtered = getFilteredRows();
22255        var total = filtered.length;
22256        var totalPages = Math.max(1, Math.ceil(total / perPage));
22257        currentPage = Math.min(currentPage, totalPages);
22258        var start = (currentPage - 1) * perPage;
22259        var end = Math.min(start + perPage, total);
22260        var shown = {};
22261        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
22262        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
22263          r.style.display = shown[r.dataset.run] ? '' : 'none';
22264        });
22265        var rl = document.getElementById('page-range-label');
22266        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
22267        var info = document.getElementById('pagination-info');
22268        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
22269        var btns = document.getElementById('pagination-btns');
22270        if (!btns) return;
22271        btns.innerHTML = '';
22272        function makeBtn(lbl, pg, active, disabled) {
22273          var b = document.createElement('button');
22274          b.className = 'pg-btn' + (active ? ' active' : '');
22275          b.textContent = lbl; b.disabled = disabled;
22276          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
22277          return b;
22278        }
22279        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
22280        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22281        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
22282        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
22283      }
22284
22285      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
22286      window.applyFilters = function() { currentPage = 1; renderPage(); };
22287
22288      // ── Sorting ───────────────────────────────────────────────────────────
22289      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
22290      function doSort(col, type, order) {
22291        var tbody = document.getElementById('compare-tbody');
22292        if (!tbody) return;
22293        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22294        rows.sort(function(a, b) {
22295          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
22296          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
22297          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
22298          return va < vb ? 1 : va > vb ? -1 : 0;
22299        });
22300        rows.forEach(function(r) { tbody.appendChild(r); });
22301        currentPage = 1; renderPage();
22302      }
22303      sortHeaders.forEach(function(th) {
22304        th.addEventListener('click', function(e) {
22305          if (e.target.classList.contains('col-resize-handle')) return;
22306          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
22307          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
22308          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22309          th.classList.add('sort-' + sortOrder);
22310          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
22311          doSort(col, type, sortOrder);
22312        });
22313      });
22314
22315      // Apply default sort (timestamp desc) on initial load
22316      (function() {
22317        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
22318        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
22319      })();
22320
22321      // ── Column resize ─────────────────────────────────────────────────────
22322      (function() {
22323        var table = document.getElementById('compare-table');
22324        if (!table) return;
22325        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
22326        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
22327        ths.forEach(function(th, i) {
22328          var handle = th.querySelector('.col-resize-handle');
22329          if (!handle || !cols[i]) return;
22330          var startX, startW;
22331          handle.addEventListener('mousedown', function(e) {
22332            e.stopPropagation(); e.preventDefault();
22333            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
22334            handle.classList.add('dragging');
22335            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
22336            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
22337            document.addEventListener('mousemove', onMove);
22338            document.addEventListener('mouseup', onUp);
22339          });
22340        });
22341      })();
22342
22343      // ── Reset view ────────────────────────────────────────────────────────
22344      window.resetView = function() {
22345        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
22346        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
22347        sortCol = null; sortOrder = 'asc';
22348        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22349        var tbody = document.getElementById('compare-tbody');
22350        if (tbody) {
22351          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22352          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
22353          rows.forEach(function(r) { tbody.appendChild(r); });
22354        }
22355        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
22356        var table = document.getElementById('compare-table');
22357        currentPage = 1; renderPage();
22358        currentPage = 1; renderPage();
22359      };
22360
22361      renderPage();
22362
22363      // ── Row selection state ───────────────────────────────────────────────
22364      var selected = [];
22365      function updateCompareBtn() {
22366        var btn = document.getElementById('compare-btn');
22367        var cnt = document.getElementById('sel-count');
22368        if (!btn) return;
22369        btn.disabled = selected.length !== 2;
22370        if (cnt) cnt.textContent = selected.length + '/2';
22371      }
22372
22373      function toggleRow(row) {
22374        var vid = row.dataset.vid || row.dataset.run;
22375        var idx = selected.indexOf(vid);
22376        if (idx >= 0) {
22377          selected.splice(idx, 1);
22378          row.classList.remove('selected');
22379          var b = document.getElementById('badge-' + vid);
22380          if (b) b.textContent = '';
22381        } else {
22382          if (selected.length >= 2) return;
22383          selected.push(vid);
22384          row.classList.add('selected');
22385        }
22386        selected.forEach(function(v, i) {
22387          var b = document.getElementById('badge-' + v);
22388          if (b) b.textContent = i + 1;
22389        });
22390        updateCompareBtn();
22391        buildScopePanel();
22392      }
22393
22394      // ── Scope panel ───────────────────────────────────────────────────────
22395      var selectedScope = 'all';
22396
22397      function buildScopePanel() {
22398        var panel = document.getElementById('scope-panel');
22399        var opts = document.getElementById('scope-options');
22400        if (!panel || !opts) return;
22401        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22402
22403        // Collect union of submodules from both selected rows.
22404        var allSubs = {};
22405        selected.forEach(function(vid) {
22406          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
22407          if (!row) return;
22408          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
22409        });
22410        var subList = Object.keys(allSubs).sort();
22411        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22412
22413        panel.classList.remove('hidden');
22414        opts.innerHTML = '';
22415
22416        function makeOption(value, label, title) {
22417          var div = document.createElement('div');
22418          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
22419          div.dataset.scopeValue = value;
22420          if (title) div.title = title;
22421          var radio = document.createElement('span');
22422          radio.className = 'scope-option-radio';
22423          var lbl = document.createElement('span');
22424          lbl.textContent = label;
22425          div.appendChild(radio);
22426          div.appendChild(lbl);
22427          div.addEventListener('click', function() {
22428            selectedScope = value;
22429            opts.querySelectorAll('.scope-option').forEach(function(o) {
22430              o.classList.toggle('selected', o.dataset.scopeValue === value);
22431            });
22432          });
22433          return div;
22434        }
22435
22436        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
22437        var sep = document.createElement('span');
22438        sep.className = 'scope-option-sep';
22439        opts.appendChild(sep);
22440        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
22441        subList.forEach(function(s) {
22442          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
22443        });
22444      }
22445
22446      function doCompare() {
22447        if (selected.length !== 2) return;
22448        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
22449        if (selectedScope === 'super') url += '&scope=super';
22450        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
22451        window.location.href = url;
22452      }
22453
22454      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
22455      var cbtn = document.getElementById('compare-btn');
22456      if (cbtn) cbtn.addEventListener('click', doCompare);
22457      var pfEl = document.getElementById('project-filter');
22458      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
22459      var bfEl = document.getElementById('branch-filter');
22460      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
22461      var rvBtn = document.getElementById('reset-view-btn');
22462      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
22463      var ppSel = document.getElementById('per-page-sel');
22464      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
22465
22466      var cmpTbody = document.getElementById('compare-tbody');
22467      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
22468        var row = e.target.closest('.compare-row');
22469        if (row) toggleRow(row);
22470      });
22471
22472      (function randomizeWatermarks() {
22473        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22474        if (!wms.length) return;
22475        var placed = [];
22476        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;}
22477        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];}
22478        var half=Math.floor(wms.length/2);
22479        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;});
22480      })();
22481
22482      (function spawnCodeParticles() {
22483        var container = document.getElementById('code-particles');
22484        if (!container) return;
22485        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'];
22486        for (var i = 0; i < 38; i++) {
22487          (function(idx) {
22488            var el = document.createElement('span');
22489            el.className = 'code-particle';
22490            el.textContent = snippets[idx % snippets.length];
22491            var left = Math.random() * 94 + 2;
22492            var top = Math.random() * 88 + 6;
22493            var dur = (Math.random() * 10 + 9).toFixed(1);
22494            var delay = (Math.random() * 18).toFixed(1);
22495            var rot = (Math.random() * 26 - 13).toFixed(1);
22496            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22497            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';
22498            container.appendChild(el);
22499          })(i);
22500        }
22501      })();
22502
22503      // ── Watched folder picker ─────────────────────────────────────────────
22504      (function() {
22505        var btn = document.getElementById('add-watched-btn');
22506        if (!btn) return;
22507        btn.addEventListener('click', function() {
22508          fetch('/pick-directory?kind=reports')
22509            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
22510            .then(function(data) {
22511              if (!data.cancelled && data.selected_path) {
22512                var form = document.createElement('form');
22513                form.method = 'POST';
22514                form.action = '/watched-dirs/add';
22515                var ri = document.createElement('input');
22516                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
22517                var fi = document.createElement('input');
22518                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
22519                form.appendChild(ri); form.appendChild(fi);
22520                document.body.appendChild(form);
22521                form.submit();
22522              }
22523            })
22524            .catch(function(e) { alert('Could not open folder picker: ' + e); });
22525        });
22526      })();
22527
22528      // ── Submodule chip truncation ─────────────────────────────────────────
22529      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
22530        var chips = cell.querySelectorAll('.submod-chip');
22531        var MAX = 4;
22532        if (chips.length <= MAX) return;
22533        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
22534        var badge = document.createElement('span');
22535        badge.className = 'submod-overflow-badge';
22536        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
22537        badge.textContent = '+' + (chips.length - MAX) + ' more';
22538        cell.appendChild(badge);
22539        cell.style.maxHeight = 'none';
22540      });
22541    })();
22542  </script>
22543  <script nonce="{{ csp_nonce }}">
22544  (function(){
22545    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'}];
22546    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);});}
22547    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22548    function init(){
22549      var btn=document.getElementById('settings-btn');if(!btn)return;
22550      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22551      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>';
22552      document.body.appendChild(m);
22553      var g=document.getElementById('scheme-grid');
22554      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);});
22555      var cl=document.getElementById('settings-close');
22556      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);
22557      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');});
22558      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22559      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22560    }
22561    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22562  }());
22563  </script>
22564  <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=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>
22565</body>
22566</html>
22567"##,
22568    ext = "html"
22569)]
22570struct CompareSelectTemplate {
22571    version: &'static str,
22572    entries: Vec<HistoryEntryRow>,
22573    total_scans: usize,
22574    watched_dirs: Vec<String>,
22575    csp_nonce: String,
22576    server_mode: bool,
22577}
22578
22579// ── CompareTemplate ────────────────────────────────────────────────────────────
22580
22581#[derive(Template)]
22582#[template(
22583    source = r##"
22584<!doctype html>
22585<html lang="en">
22586<head>
22587  <meta charset="utf-8">
22588  <meta name="viewport" content="width=device-width, initial-scale=1">
22589  <title>OxideSLOC | Scan Delta</title>
22590  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22591  <style nonce="{{ csp_nonce }}">
22592    :root {
22593      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
22594      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
22595      --nav:#283790; --nav-2:#013e6b;
22596      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
22597      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
22598      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
22599    }
22600    body.dark-theme {
22601      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
22602      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
22603    }
22604    *{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;}
22605    .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);}
22606    .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;}
22607    .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));}
22608    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22609    .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;}
22610    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
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;white-space:nowrap;text-decoration:none;}
22614    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22615    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22616    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22617    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22618    .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;}
22619    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22620    .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);}
22621    .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;}
22622    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22623    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22624    .settings-modal-body{padding:14px 16px 16px;}
22625    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22626    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22627    .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;}
22628    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22629    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22630    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22631    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22632    .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;}
22633    .tz-select:focus{border-color:var(--oxide);}
22634    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
22635    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22636    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
22637    .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;}
22638    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
22639    .hero-body{display:block;}
22640    .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;}
22641    .btn-back:hover{background:var(--line);}
22642    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
22643    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
22644    .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;}
22645    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
22646    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;}
22647    .muted{color:var(--muted);font-size:14px;}
22648    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
22649    .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;}
22650    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
22651    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
22652    .vpill-arrow{font-size:20px;color:var(--muted);}
22653    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
22654    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
22655    .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;}
22656    .delta-card.delta-card-wide{padding:22px 24px;}
22657    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
22658    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
22659    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
22660    .delta-card-from{font-size:15px;color:var(--muted);}
22661    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
22662    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
22663    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
22664    .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%;}
22665    .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;}
22666    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
22667    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
22668    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
22669    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
22670    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
22671    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
22672    .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;}
22673    .meta-card-commit:hover{color:var(--oxide);}
22674    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
22675    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
22676    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
22677    .meta-value{color:var(--text);font-size:13px;}
22678    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
22679    .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.55;width:230px;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;}
22680    .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);}
22681    .delta-card:hover .dc-tip{display:block;}
22682    .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;}
22683    .export-btn:hover{background:var(--line);}
22684    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
22685    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
22686    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
22687    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
22688    .delta-card-change.zero{color:var(--muted);background:transparent;}
22689    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
22690    .delta-card-pct.pos{color:var(--pos);}
22691    .delta-card-pct.neg{color:var(--neg);}
22692    .delta-card-pct.zero{color:var(--muted);}
22693    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
22694    .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;}
22695    .insight-card.insight-flag{border-color:var(--oxide);}
22696    .insight-card:hover .dc-tip{display:block;}
22697    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
22698    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
22699    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
22700    .insight-label.flag{color:var(--oxide);}
22701    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
22702    .insight-val.pos{color:var(--pos);}
22703    .insight-val.neg{color:var(--neg);}
22704    .insight-val.high{color:#c0392a;}
22705    .insight-val.med{color:#926000;}
22706    .insight-val.low{color:var(--pos);}
22707    body.dark-theme .insight-val.high{color:#ff6b6b;}
22708    body.dark-theme .insight-val.med{color:#f0c060;}
22709    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
22710    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
22711    .fc-row{display:flex;align-items:center;gap:8px;}
22712    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
22713    .fc-label{color:var(--muted);}
22714    .fc-modified .fc-count{color:#926000;}
22715    .fc-added .fc-count{color:var(--pos);}
22716    .fc-removed .fc-count{color:var(--neg);}
22717    .fc-unchanged .fc-count{color:var(--muted);}
22718    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
22719    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
22720    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
22721    .chip.modified{background:#fff2d8;color:#926000;}
22722    .chip.added{background:#e8f5ed;color:#1a8f47;}
22723    .chip.removed{background:#fdeaea;color:#b33b3b;}
22724    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
22725    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
22726    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
22727    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
22728    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
22729    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
22730    .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;}
22731    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
22732    .tab-btn:hover:not(.active){background:var(--line);}
22733    .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;}
22734    .btn-reset:hover{background:var(--line);}
22735    .table-wrap{width:100%;overflow-x:auto;}
22736    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
22737    th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
22738    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
22739    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
22740    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
22741    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
22742    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
22743    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
22744    tr:last-child td{border-bottom:none;}
22745    tr.row-added td{background:rgba(26,143,71,0.06);}
22746    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
22747    tr.row-modified td{background:rgba(146,96,0,0.05);}
22748    tr.row-unchanged td{opacity:.6;}
22749    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
22750    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
22751    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
22752    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
22753    .status-badge.modified{background:#fff2d8;color:#926000;}
22754    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
22755    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
22756    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
22757    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
22758    .delta-val{font-weight:700;}
22759    .delta-val.pos{color:var(--pos);}
22760    .delta-val.neg{color:var(--neg);}
22761    .delta-val.zero{color:var(--muted);}
22762    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
22763    .from-to strong{color:var(--text);}
22764    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22765    .site-footer a{color:var(--muted);}
22766    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
22767    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
22768    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22769    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22770    .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;}
22771    .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;}
22772    .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;}
22773    @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));}}
22774    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
22775    .path-link:hover{color:var(--oxide-2);}
22776    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
22777    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
22778    a.vpill-id:hover{color:var(--oxide);}
22779    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
22780    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
22781    .pagination-info{font-size:13px;color:var(--muted);}
22782    .pagination-btns{display:flex;gap:6px;}
22783    .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;}
22784    .pg-btn:hover:not(:disabled){background:var(--line);}
22785    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22786    .pg-btn:disabled{opacity:.35;cursor:default;}
22787    .per-page-label{font-size:13px;color:var(--muted);}
22788    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;}
22789    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22790    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
22791    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
22792    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
22793    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
22794    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
22795    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
22796    .tab-btn.tab-unchanged{color:var(--muted);}
22797    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
22798    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
22799    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
22800    .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;}
22801    .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;}
22802    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
22803    .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;}
22804    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
22805    .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;}
22806    .submod-scope-btn:hover{background:var(--line);}
22807    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22808    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
22809    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
22810    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
22811    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
22812    body.dark-theme .ic-card{background:var(--surface-2);}
22813    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
22814    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
22815    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
22816    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
22817    #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;}
22818  </style>
22819</head>
22820<body>
22821  <div class="background-watermarks" aria-hidden="true">
22822    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22823    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22824    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22825    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22826    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22827    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22828  </div>
22829  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22830  <div class="top-nav">
22831    <div class="top-nav-inner">
22832      <a class="brand" href="/">
22833        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22834        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
22835      </a>
22836      <div class="nav-right">
22837        <a class="nav-pill" href="/">Home</a>
22838        <div class="nav-dropdown">
22839          <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>
22840          <div class="nav-dropdown-menu">
22841            <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>
22842          </div>
22843        </div>
22844        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22845        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22846        <div class="nav-dropdown">
22847          <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>
22848          <div class="nav-dropdown-menu">
22849            <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>
22850          </div>
22851        </div>
22852        <div class="server-status-wrap" id="server-status-wrap">
22853          <div class="nav-pill server-online-pill" id="server-status-pill">
22854            <span class="status-dot" id="status-dot"></span>
22855            <span id="server-status-label">Server</span>
22856            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22857          </div>
22858          <div class="server-status-tip">
22859            OxideSLOC is running — accessible on your network.
22860            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22861          </div>
22862        </div>
22863        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22864          <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>
22865        </button>
22866        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22867          <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>
22868          <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>
22869        </button>
22870      </div>
22871    </div>
22872  </div>
22873
22874  <div class="page">
22875    <section class="hero">
22876      <div class="hero-header">
22877        <div>
22878          <h1 class="delta-title">Scan Delta</h1>
22879          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
22880          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
22881            {% if let Some(sub) = active_submodule %}
22882            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
22883            {% else if super_scope_active %}
22884            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
22885            {% else %}
22886            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
22887            {% endif %}
22888            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
22889          </div>
22890        </div>
22891        <a class="btn-back" href="/compare-scans">
22892          <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>
22893          Compare Scans
22894        </a>
22895      </div>
22896      {% if has_any_submodule_data %}
22897      <div class="submod-scope-bar">
22898        <span class="submod-scope-label">
22899          <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>
22900          Scope:
22901        </span>
22902        <div class="submod-scope-divider"></div>
22903        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
22904           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
22905           title="All files — super-repo and all submodules combined">Full scan</a>
22906        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
22907           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
22908           title="Only files that are not part of any submodule">Super-repo only</a>
22909        {% for sub in submodule_options %}
22910        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
22911           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
22912           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
22913        {% endfor %}
22914      </div>
22915      {% endif %}
22916      <div class="hero-body">
22917      <div class="meta-strip">
22918        <div class="delta-card delta-card-meta">
22919          <div class="meta-card-header">
22920            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
22921            <div class="meta-card-project-col">
22922              <div class="meta-card-project">{{ project_name }}</div>
22923              {% if has_any_submodule_data %}
22924              {% if let Some(sub) = active_submodule %}
22925              <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>
22926              {% else if super_scope_active %}
22927              <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>
22928              {% else %}
22929              <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>
22930              {% endif %}
22931              {% endif %}
22932            </div>
22933          </div>
22934          {% if !baseline_git_commit.is_empty() %}
22935          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
22936          {% else %}
22937          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
22938          {% endif %}
22939          <div class="meta-card-rows">
22940            <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>
22941            <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>
22942            <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>
22943            <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>
22944            {% if let Some(tags) = baseline_git_tags %}
22945            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22946            {% endif %}
22947          </div>
22948        </div>
22949        <div class="delta-card delta-card-meta">
22950          <div class="meta-card-header">
22951            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
22952            <div class="meta-card-project-col">
22953              <div class="meta-card-project">{{ project_name }}</div>
22954              {% if has_any_submodule_data %}
22955              {% if let Some(sub) = active_submodule %}
22956              <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>
22957              {% else if super_scope_active %}
22958              <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>
22959              {% else %}
22960              <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>
22961              {% endif %}
22962              {% endif %}
22963            </div>
22964          </div>
22965          {% if !current_git_commit.is_empty() %}
22966          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
22967          {% else %}
22968          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
22969          {% endif %}
22970          <div class="meta-card-rows">
22971            <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>
22972            <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>
22973            <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>
22974            <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>
22975            {% if let Some(tags) = current_git_tags %}
22976            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22977            {% endif %}
22978          </div>
22979        </div>
22980      </div>
22981      <div class="delta-strip">
22982        <div class="delta-card">
22983          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
22984          <div class="delta-card-label">Code lines</div>
22985          <div class="delta-card-from">Before: {{ baseline_code }}</div>
22986          <div class="delta-card-to">{{ current_code }}</div>
22987          {% 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>
22988          {% 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>
22989          {% else %}<div class="delta-card-pct zero">±0%</div>
22990          {% endif %}
22991        </div>
22992        <div class="delta-card">
22993          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
22994          <div class="delta-card-label">Files analyzed</div>
22995          <div class="delta-card-from">Before: {{ baseline_files }}</div>
22996          <div class="delta-card-to">{{ current_files }}</div>
22997          {% 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>
22998          {% 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>
22999          {% else %}<div class="delta-card-pct zero">±0%</div>
23000          {% endif %}
23001        </div>
23002        <div class="delta-card">
23003          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
23004          <div class="delta-card-label">Comment lines</div>
23005          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
23006          <div class="delta-card-to">{{ current_comments }}</div>
23007          {% 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>
23008          {% 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>
23009          {% else %}<div class="delta-card-pct zero">±0%</div>
23010          {% endif %}
23011        </div>
23012        {{ coverage_delta_card|safe }}
23013        <div class="delta-card delta-card-wide">
23014          <div class="dc-tip">Per-file breakdown. Modified = at least one count changed. Unchanged = identical counts in both scans. Added/Removed = only in one scan.</div>
23015          <div class="delta-card-label">File changes</div>
23016          <div class="file-changes-grid">
23017            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
23018            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
23019            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
23020            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
23021          </div>
23022        </div>
23023      </div>
23024      <div class="insights-panel">
23025        <div class="insight-card">
23026          <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans. Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
23027          <div class="insight-label">Lines Added</div>
23028          <div class="insight-val pos">+{{ code_lines_added }}</div>
23029          <div class="insight-sub">New or grown source lines</div>
23030        </div>
23031        <div class="insight-card">
23032          <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans. Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
23033          <div class="insight-label">Lines Removed</div>
23034          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
23035          <div class="insight-sub">Deleted or shrunk source lines</div>
23036        </div>
23037        <div class="insight-card">
23038          <div class="dc-tip up">Measures total editing activity relative to codebase size. Formula: (lines added + lines removed) ÷ baseline code lines × 100%. Above 20% = high activity, 5–20% = normal velocity, below 5% = stable.</div>
23039          <div class="insight-label">Churn Rate</div>
23040          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
23041          <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>
23042        </div>
23043        {% if scope_flag %}
23044        <div class="insight-card insight-flag">
23045          <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new. Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline. This often signals a large feature branch, a bulk import, or a generated-file inclusion. Review the file-level delta below to confirm scope.{% endif %}</div>
23046          <div class="insight-label flag">Scope Signal</div>
23047          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
23048          <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>
23049        </div>
23050        {% endif %}
23051      </div>
23052      </div>
23053    </section>
23054
23055    <section class="panel" id="inline-charts-section">
23056      <h2>Scan Delta Charts</h2>
23057      <div class="ic-grid">
23058        <div class="ic-card">
23059          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
23060          <div class="ic-leg"><span><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span><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>
23061          <div id="ic-c1"></div>
23062        </div>
23063        <div class="ic-card" id="ic-lang-card">
23064          <div class="ic-card-h2">Language Code Delta</div>
23065          <div id="ic-c3"></div>
23066        </div>
23067        <div class="ic-card">
23068          <div class="ic-card-h2">Delta by Metric</div>
23069          <div id="ic-c2"></div>
23070        </div>
23071        <div class="ic-card">
23072          <div class="ic-card-h2">File Change Distribution</div>
23073          <div id="ic-c4"></div>
23074        </div>
23075      </div>
23076    </section>
23077
23078    <section class="panel">
23079      <h2>File-level delta</h2>
23080      <div class="filter-tabs-row">
23081        <div class="filter-tabs">
23082          <button class="tab-btn tab-all active" data-filter="all">All</button>
23083          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
23084          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
23085          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
23086          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
23087        </div>
23088        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
23089          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
23090          <div class="export-group">
23091            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
23092            <button type="button" class="export-btn" id="delta-csv-btn">
23093              <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>
23094              CSV
23095            </button>
23096            <button type="button" class="export-btn" id="delta-xls-btn">
23097              <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>
23098              Excel
23099            </button>
23100            <button type="button" class="export-btn" id="delta-charts-btn">
23101              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="2" y1="20" x2="22" y2="20"/><rect x="3" y="13" width="4" height="7" rx="1"/><rect x="10" y="7" width="4" height="13" rx="1"/><rect x="17" y="2" width="4" height="18" rx="1"/></svg>
23102              Charts
23103            </button>
23104          </div>
23105        </div>
23106      </div>
23107
23108      <div class="table-wrap">
23109      <table id="delta-table">
23110        <colgroup>
23111          <col>
23112          <col>
23113          <col>
23114          <col>
23115          <col>
23116          <col>
23117          <col>
23118        </colgroup>
23119        <thead>
23120          <tr id="delta-thead">
23121            <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>
23122            <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>
23123            <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>
23124            <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>
23125            <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>
23126            <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>
23127            <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>
23128          </tr>
23129        </thead>
23130        <tbody id="delta-tbody">
23131          {% for row in file_rows %}
23132          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
23133              data-path="{{ row.relative_path }}"
23134              data-language="{{ row.language }}"
23135              data-baseline-code="{{ row.baseline_code }}"
23136              data-current-code="{{ row.current_code }}"
23137              data-code-delta="{{ row.code_delta_str }}"
23138              data-comment-delta="{{ row.comment_delta_str }}"
23139              data-total-delta="{{ row.total_delta_str }}"
23140              data-orig-idx="">
23141            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
23142            <td class="hide-sm">{{ row.language }}</td>
23143            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
23144            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
23145            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
23146            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
23147            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
23148          </tr>
23149          {% endfor %}
23150        </tbody>
23151      </table>
23152      </div>
23153      <div class="pagination">
23154        <span class="pagination-info" id="pg-info"></span>
23155        <div class="pagination-btns" id="pg-btns"></div>
23156        <div class="flex-row">
23157          <span class="per-page-label">Show</span>
23158          <select class="per-page" id="per-page-sel">
23159            <option value="10">10 per page</option>
23160            <option value="25" selected>25 per page</option>
23161            <option value="50">50 per page</option>
23162            <option value="100">100 per page</option>
23163          </select>
23164          <span class="per-page-label" id="pg-range-label"></span>
23165        </div>
23166      </div>
23167    </section>
23168  </div>
23169
23170  <div id="ic-tt"></div>
23171
23172  <footer class="site-footer">
23173    local code analysis - metrics, history and reports
23174    &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>
23175    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23176    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23177    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23178    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23179  </footer>
23180
23181  <script nonce="{{ csp_nonce }}">
23182    (function () {
23183      var storageKey = 'oxide-sloc-theme';
23184      var body = document.body;
23185      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23186      var toggle = document.getElementById('theme-toggle');
23187      if (toggle) toggle.addEventListener('click', function () {
23188        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23189        body.classList.toggle('dark-theme', next === 'dark');
23190        try { localStorage.setItem(storageKey, next); } catch(e) {}
23191      });
23192
23193      (function randomizeWatermarks() {
23194        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23195        if (!wms.length) return;
23196        var placed = [];
23197        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;}
23198        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];}
23199        var half=Math.floor(wms.length/2);
23200        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;});
23201      })();
23202
23203      (function spawnCodeParticles() {
23204        var container = document.getElementById('code-particles');
23205        if (!container) return;
23206        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'];
23207        for (var i = 0; i < 38; i++) {
23208          (function(idx) {
23209            var el = document.createElement('span');
23210            el.className = 'code-particle';
23211            el.textContent = snippets[idx % snippets.length];
23212            var left = Math.random() * 94 + 2;
23213            var top = Math.random() * 88 + 6;
23214            var dur = (Math.random() * 10 + 9).toFixed(1);
23215            var delay = (Math.random() * 18).toFixed(1);
23216            var rot = (Math.random() * 26 - 13).toFixed(1);
23217            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23218            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';
23219            container.appendChild(el);
23220          })(i);
23221        }
23222      })();
23223    })();
23224
23225    var activeStatusFilter = 'all';
23226    var deltaPerPage = 25, deltaCurrPage = 1;
23227
23228    function openFolder(path) {
23229      fetch('/open-path?path=' + encodeURIComponent(path))
23230        .then(function (r) { return r.json(); })
23231        .then(function (d) {
23232          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23233        })
23234        .catch(function () {});
23235    }
23236
23237    function getDeltaFilteredRows() {
23238      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
23239        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
23240      });
23241    }
23242
23243    function renderDeltaPage() {
23244      var filtered = getDeltaFilteredRows();
23245      var total = filtered.length;
23246      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
23247      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
23248      var start = (deltaCurrPage - 1) * deltaPerPage;
23249      var end = Math.min(start + deltaPerPage, total);
23250      var shownSet = {};
23251      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
23252      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
23253        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
23254      });
23255      var rl = document.getElementById('pg-range-label');
23256      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
23257      var info = document.getElementById('pg-info');
23258      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
23259      var btns = document.getElementById('pg-btns');
23260      if (!btns) return;
23261      btns.innerHTML = '';
23262      if (totalPages <= 1) return;
23263      function makeBtn(lbl, pg, active, disabled) {
23264        var b = document.createElement('button');
23265        b.className = 'pg-btn' + (active ? ' active' : '');
23266        b.textContent = lbl; b.disabled = disabled;
23267        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
23268        return b;
23269      }
23270      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
23271      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
23272      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
23273      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
23274    }
23275
23276    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
23277
23278    function filterRows(status, btn) {
23279      activeStatusFilter = status;
23280      deltaCurrPage = 1;
23281      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
23282        b.classList.remove('active');
23283      });
23284      if (btn) btn.classList.add('active');
23285      renderDeltaPage();
23286    }
23287
23288    // ── Sorting ──────────────────────────────────────────────────────────────
23289    var sortCol = null, sortOrder = 'asc';
23290    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
23291    (function() {
23292      var tbody = document.getElementById('delta-tbody');
23293      if (!tbody) return;
23294      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23295      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
23296    })();
23297
23298    function parseDeltaNum(str) {
23299      if (!str || str === '—') return 0;
23300      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
23301    }
23302
23303    sortHeaders.forEach(function(th) {
23304      th.addEventListener('click', function(e) {
23305        if (e.target.classList.contains('col-resize-handle')) return;
23306        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
23307        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
23308        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23309        th.classList.add('sort-' + sortOrder);
23310        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
23311        var tbody = document.getElementById('delta-tbody');
23312        if (!tbody) return;
23313        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23314        rows.sort(function(a, b) {
23315          var va, vb;
23316          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
23317          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
23318          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
23319          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
23320          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23321          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23322          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23323          else { va = ''; vb = ''; }
23324          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
23325          return va < vb ? 1 : va > vb ? -1 : 0;
23326        });
23327        rows.forEach(function(r) { tbody.appendChild(r); });
23328        deltaCurrPage = 1;
23329        renderDeltaPage();
23330        var activeBtn = document.querySelector('.tab-btn.active');
23331        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23332        if (activeBtn) activeBtn.classList.add('active');
23333      });
23334    });
23335
23336    // ── Column resize ─────────────────────────────────────────────────────────
23337    (function() {
23338      var table = document.getElementById('delta-table');
23339      if (!table) return;
23340      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
23341      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
23342      ths.forEach(function(th, i) {
23343        var handle = th.querySelector('.col-resize-handle');
23344        if (!handle || !cols[i]) return;
23345        var startX, startW;
23346        handle.addEventListener('mousedown', function(e) {
23347          e.stopPropagation(); e.preventDefault();
23348          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
23349          handle.classList.add('dragging');
23350          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
23351          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
23352          document.addEventListener('mousemove', onMove);
23353          document.addEventListener('mouseup', onUp);
23354        });
23355      });
23356    })();
23357
23358    // ── Reset ─────────────────────────────────────────────────────────────────
23359    window.resetDeltaTable = function() {
23360      sortCol = null; sortOrder = 'asc';
23361      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23362      var tbody = document.getElementById('delta-tbody');
23363      if (tbody) {
23364        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23365        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
23366        rows.forEach(function(r) { tbody.appendChild(r); });
23367      }
23368      var table = document.getElementById('delta-table');
23369      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
23370      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
23371      activeStatusFilter = 'all';
23372      deltaCurrPage = 1;
23373      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23374      var allBtn = document.querySelector('.tab-btn');
23375      if (allBtn) allBtn.classList.add('active');
23376      renderDeltaPage();
23377    };
23378
23379    renderDeltaPage();
23380
23381    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
23382    (function() {
23383      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
23384        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
23385      });
23386      var resetBtn = document.getElementById('delta-reset-btn');
23387      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
23388      var csvBtn = document.getElementById('delta-csv-btn');
23389      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
23390      var xlsBtn = document.getElementById('delta-xls-btn');
23391      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
23392      var chartsBtn = document.getElementById('delta-charts-btn');
23393      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
23394      var ppSel = document.getElementById('per-page-sel');
23395      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
23396      var pathLink = document.getElementById('project-path-link');
23397      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
23398    })();
23399
23400    // ── Export helpers ────────────────────────────────────────────────────────
23401    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
23402    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
23403    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);}
23404    function slocMakeXlsx(fname,sd,dr){
23405      var enc=new TextEncoder();
23406      // CRC-32 table
23407      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;}
23408      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;}
23409      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
23410      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
23411      // Shared string table
23412      var ss=[],si={};
23413      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
23414      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23415      // Worksheet builder — each WS() call gets its own row counter R
23416      function WS(){
23417        var R=0,buf=[];
23418        function cl(c){return String.fromCharCode(65+c);}
23419        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
23420          '<v>'+S(v)+'</v></c>';}
23421        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
23422          (st?' s="'+st+'"':'')+'>'+
23423          '<v>'+(+v)+'</v></c>';}
23424        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
23425        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23426          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
23427          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
23428          '<sheetFormatPr defaultRowHeight="15"/>'+
23429          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
23430        return{sc:sc,nc:nc,row:row,xml:xml};
23431      }
23432      // Language breakdown
23433      var lm={};
23434      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;});
23435      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
23436      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
23437      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
23438      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
23439      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23440      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23441      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):'';}
23442      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
23443      // Summary sheet
23444      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
23445      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
23446      r1(s1(0,proj,2));
23447      r1(s1(0,sd.bts+' → '+sd.cts,2));
23448      r1('');
23449      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
23450      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))));
23451      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))));
23452      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))));
23453      r1('');
23454      r1(s1(0,'FILE CHANGES',8));
23455      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
23456      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
23457      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
23458      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
23459      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
23460      if(langs.length){
23461        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
23462        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
23463        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)));});
23464      }
23465      r1('');r1(s1(0,'SCAN METADATA',8));
23466      r1(s1(1,_blabel)+s1(2,_clabel));
23467      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
23468      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
23469      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"/>');
23470      // File Delta sheet
23471      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
23472      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));
23473      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)));});
23474      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
23475      // Shared strings XML
23476      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23477        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
23478        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
23479      // XLSX file map
23480      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
23481      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>',
23482        '_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>',
23483        '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>',
23484        '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>',
23485        '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>',
23486        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
23487      // ZIP packer — STORED (no compression), compatible with all XLSX readers
23488      var zparts=[],zcds=[],zoff=0,znf=0;
23489      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
23490       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
23491      ].forEach(function(name){
23492        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
23493        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]);
23494        var entry=new Uint8Array(lha.length+nb.length+sz);
23495        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
23496        zparts.push(entry);
23497        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));
23498        var cde=new Uint8Array(cda.length+nb.length);
23499        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
23500        zcds.push(cde);zoff+=entry.length;znf++;
23501      });
23502      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
23503      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]);
23504      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
23505      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
23506      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
23507      zout.set(new Uint8Array(ea),zpos);
23508      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
23509      var xurl=URL.createObjectURL(xblob);
23510      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
23511      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
23512      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
23513    }
23514    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;');}
23515    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
23516    function getExportFilename(ext){return _exportBase+'.'+ext;}
23517
23518    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 }}'};
23519    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;}
23520    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
23521    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
23522    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23523    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23524    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):'';}
23525    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
23526    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)]];}
23527    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
23528    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;}
23529    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
23530    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
23531
23532    // ── Chart HTML report ─────────────────────────────────────────────────────
23533    function slocChartReport(fname, sd, dr) {
23534      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
23535      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23536      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23537      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();}
23538      function px(n){return Math.round(n);}
23539      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
23540      // Language map
23541      var lm={};
23542      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;});
23543      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23544
23545      // Builds onmouse* attrs for interactive tooltip on each SVG element
23546      function barTT(label,val){
23547        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
23548      }
23549
23550      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
23551      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'}];
23552      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23553      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
23554      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23555      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23556      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"/>';}
23557      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23558      c1mets.forEach(function(m,i){
23559        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23560        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23561        c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
23562        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))+'/>';
23563        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
23564        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))+'/>';
23565        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
23566        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>';
23567        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>';
23568      });
23569      c1+='</svg>';
23570
23571      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
23572      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'}];
23573      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23574      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
23575      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
23576      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23577      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23578      mets.forEach(function(m,i){
23579        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
23580        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
23581        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
23582        c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
23583        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
23584        if(bw>=52){
23585          c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
23586        }else{
23587          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
23588          c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
23589        }
23590      });
23591      c2+='</svg>';
23592
23593      // ── Chart 3: Language Code Delta ─────────────────────────────────────
23594      var c3='';
23595      if(langs.length){
23596        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23597        var C3W=550,c3LW=124,c3FW=52;
23598        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
23599        var L3rH=30,C3H=langs.length*L3rH+20;
23600        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23601        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23602        langs.forEach(function(l,i){
23603          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
23604          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
23605          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
23606          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23607          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':''))+'/>';
23608          if(bw>=48){
23609            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>';
23610          }else{
23611            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
23612            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>';
23613          }
23614          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>';
23615        });
23616        c3+='</svg>';
23617      }
23618
23619      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
23620      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;});
23621      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23622      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
23623      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23624      var ang=-Math.PI/2;
23625      segs.forEach(function(s){
23626        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23627        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
23628        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
23629        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
23630        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
23631        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)+'%')+'/>';
23632        ang+=sw;
23633      });
23634      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>';
23635      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23636      segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#333">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
23637      c4+='</svg>';
23638
23639      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
23640      var ttJs='var tt=document.getElementById("ox-tt");'+
23641        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
23642        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
23643        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
23644        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
23645        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
23646        'function oxHT(){tt.style.display="none";}';
23647
23648      // body max-width keeps charts from inflating beyond design dimensions on
23649      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
23650      // each chart's height blows up proportionally, breaking the one-page layout.
23651      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;}'+
23652        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
23653        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
23654        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
23655        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
23656        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
23657        'svg{display:block;}'+
23658        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
23659        '#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;}'+
23660        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
23661      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
23662        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
23663        '<div id="ox-tt"><\/div>'+
23664        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
23665        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
23666        '<div class="two-col">'+
23667        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
23668        '<div class="leg">'+
23669        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
23670        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
23671        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
23672        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
23673        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
23674        '<\/div>'+
23675        '<div class="two-col">'+
23676        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
23677        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
23678        '<\/div>'+
23679        '<script>'+ttJs+'<\/script>'+
23680        '<\/body><\/html>';
23681      slocDownload(html, fname, 'text/html;charset=utf-8;');
23682    }
23683    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
23684    // ── Inline delta charts ────────────────────────────────────────────────────
23685    var _icTT=document.getElementById('ic-tt');
23686    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
23687    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';};
23688    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
23689    window.addEventListener('blur',function(){window.icHT();});
23690    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
23691    (function(){
23692      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
23693      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23694      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();}
23695      function px(n){return Math.round(n);}
23696      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23697      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
23698      function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t)icTT(e,t.getAttribute('data-ttl'),t.getAttribute('data-ttv'));});el.addEventListener('mouseout',function(e){if(!e.relatedTarget||!el.contains(e.relatedTarget))icHT();});el.addEventListener('mousemove',function(e){icMT(e);});}
23699      var dr=getDeltaExportRows(),sd=_sd,lm={};
23700      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;});
23701      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23702      // Chart 1: Baseline vs Current grouped bars
23703      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'}];
23704      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23705      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23706      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23707      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"/>';}
23708      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23709      c1mets.forEach(function(m,i){
23710        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23711        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23712        c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
23713        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"/>';
23714        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
23715        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"/>';
23716        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
23717        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>';
23718        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>';
23719      });
23720      c1+='</svg>';
23721      // Chart 2: Delta by Metric
23722      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'}];
23723      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23724      var C2W=530,rH=48,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;
23725      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23726      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23727      mets.forEach(function(m,i){
23728        var y=14+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);
23729        c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
23730        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
23731        if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
23732        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+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
23733      });
23734      c2+='</svg>';
23735      // Chart 3: Language Code Delta
23736      var c3='';
23737      if(langs.length){
23738        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23739        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;
23740        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23741        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23742        langs.forEach(function(l,i){
23743          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);
23744          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23745          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"/>';
23746          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>';}
23747          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>';}
23748          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>';
23749        });
23750        c3+='</svg>';
23751      }
23752      // Chart 4: File Change Donut
23753      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;});
23754      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23755      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210,c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
23756      if(segs.length===1){
23757        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
23758        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
23759        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
23760      } else {
23761        segs.forEach(function(s){
23762          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23763          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);
23764          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);
23765          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"/>';
23766          ang+=sw;
23767        });
23768      }
23769      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>';
23770      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23771      segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
23772      c4+='</svg>';
23773      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
23774      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
23775      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);}
23776      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
23777      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
23778      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
23779    })();
23780  </script>
23781  <script nonce="{{ csp_nonce }}">
23782  (function(){
23783    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'}];
23784    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);});}
23785    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23786    function init(){
23787      var btn=document.getElementById('settings-btn');if(!btn)return;
23788      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23789      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>';
23790      document.body.appendChild(m);
23791      var g=document.getElementById('scheme-grid');
23792      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);});
23793      var cl=document.getElementById('settings-close');
23794      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);
23795      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');});
23796      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23797      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23798    }
23799    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23800  }());
23801  </script>
23802  <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=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>
23803</body>
23804</html>
23805"##,
23806    ext = "html"
23807)]
23808// Template structs need many bool fields to pass Askama rendering flags.
23809#[allow(clippy::struct_excessive_bools)]
23810struct CompareTemplate {
23811    version: &'static str,
23812    project_label: String,
23813    baseline_git_commit: String,
23814    current_git_commit: String,
23815    baseline_run_id: String,
23816    current_run_id: String,
23817    baseline_run_id_short: String,
23818    current_run_id_short: String,
23819    baseline_timestamp: String,
23820    baseline_timestamp_utc_ms: i64,
23821    current_timestamp: String,
23822    current_timestamp_utc_ms: i64,
23823    project_path: String,
23824    baseline_code: u64,
23825    current_code: u64,
23826    code_lines_delta_str: String,
23827    code_lines_delta_class: String,
23828    baseline_files: u64,
23829    current_files: u64,
23830    files_analyzed_delta_str: String,
23831    files_analyzed_delta_class: String,
23832    baseline_comments: u64,
23833    current_comments: u64,
23834    comment_lines_delta_str: String,
23835    comment_lines_delta_class: String,
23836    code_lines_pct_str: String,
23837    files_analyzed_pct_str: String,
23838    comment_lines_pct_str: String,
23839    code_lines_added: i64,
23840    code_lines_removed: i64,
23841    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
23842    new_scope: bool,
23843    churn_rate_str: String,
23844    churn_rate_class: String,
23845    scope_flag: bool,
23846    files_added: usize,
23847    files_removed: usize,
23848    files_modified: usize,
23849    files_unchanged: usize,
23850    file_rows: Vec<CompareFileDeltaRow>,
23851    baseline_git_author: Option<String>,
23852    current_git_author: Option<String>,
23853    baseline_git_branch: String,
23854    current_git_branch: String,
23855    baseline_git_tags: Option<String>,
23856    current_git_tags: Option<String>,
23857    baseline_git_commit_date: Option<String>,
23858    current_git_commit_date: Option<String>,
23859    project_name: String,
23860    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
23861    submodule_options: Vec<String>,
23862    /// True when either run has submodule data — controls whether the scope bar is shown.
23863    has_any_submodule_data: bool,
23864    /// The submodule currently being compared, if the `sub` query param was provided.
23865    active_submodule: Option<String>,
23866    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
23867    super_scope_active: bool,
23868    csp_nonce: String,
23869    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
23870    coverage_delta_card: String,
23871}
23872
23873// ── LoginTemplate ──────────────────────────────────────────────────────────────
23874
23875#[derive(Template)]
23876#[template(
23877    source = r##"
23878<!doctype html>
23879<html lang="en">
23880<head>
23881  <meta charset="utf-8">
23882  <meta name="viewport" content="width=device-width, initial-scale=1">
23883  <title>OxideSLOC | Sign In</title>
23884  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23885  <style nonce="{{ csp_nonce }}">
23886    :root {
23887      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
23888      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
23889      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
23890      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
23891    }
23892    *{box-sizing:border-box;}
23893    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);}
23894    .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);}
23895    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
23896    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
23897    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
23898    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23899    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23900    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23901    .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;}
23902    @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));}}
23903    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
23904    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
23905    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
23906    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
23907    .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;}
23908    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
23909    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;}
23910    input[type=password]:focus{border-color:var(--oxide);}
23911    .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;}
23912    .btn:hover{opacity:.88;}
23913    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
23914    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
23915  </style>
23916</head>
23917<body>
23918  <div class="background-watermarks" aria-hidden="true">
23919    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23920    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23921    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23922    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23923    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23924    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23925    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23926  </div>
23927  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23928<nav class="top-nav">
23929  <a class="brand" href="/">
23930    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
23931    <span class="brand-title">OxideSLOC</span>
23932  </a>
23933</nav>
23934<main class="page">
23935  <div class="card">
23936    <h1>Sign In</h1>
23937    <p class="subtitle">Enter the API key printed when the server started.</p>
23938    {% if has_error %}
23939    <div class="error">Incorrect API key — please try again.</div>
23940    {% endif %}
23941    <form method="POST" action="/auth/login">
23942      <input type="hidden" name="next" value="{{ next_url|e }}">
23943      <label for="key">API Key</label>
23944      <input id="key" type="password" name="key" autocomplete="current-password"
23945             placeholder="Paste your API key here" autofocus>
23946      <button type="submit" class="btn">Sign In</button>
23947    </form>
23948    <p class="hint">
23949      The API key was printed in the terminal when the server started.<br>
23950      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
23951      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
23952    </p>
23953  </div>
23954</main>
23955<script nonce="{{ csp_nonce }}">
23956(function() {
23957  (function randomizeWatermarks() {
23958    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23959    if (!wms.length) return;
23960    var placed = [];
23961    function tooClose(top, left) {
23962      for (var i = 0; i < placed.length; i++) {
23963        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
23964        if (dt < 16 && dl < 12) return true;
23965      }
23966      return false;
23967    }
23968    function pick(leftBand) {
23969      for (var attempt = 0; attempt < 50; attempt++) {
23970        var top = Math.random() * 88 + 2;
23971        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23972        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
23973      }
23974      var top = Math.random() * 88 + 2;
23975      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23976      placed.push([top, left]); return [top, left];
23977    }
23978    var half = Math.floor(wms.length / 2);
23979    wms.forEach(function (img, i) {
23980      var pos = pick(i < half);
23981      var size = Math.floor(Math.random() * 100 + 120);
23982      var rot = (Math.random() * 360).toFixed(1);
23983      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
23984      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;
23985    });
23986  })();
23987  (function spawnCodeParticles() {
23988    var container = document.getElementById('code-particles');
23989    if (!container) return;
23990    var snippets = [
23991      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
23992      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
23993      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
23994      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
23995      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
23996    ];
23997    var count = 38;
23998    for (var i = 0; i < count; i++) {
23999      (function(idx) {
24000        var el = document.createElement('span');
24001        el.className = 'code-particle';
24002        el.textContent = snippets[idx % snippets.length];
24003        var left = Math.random() * 94 + 2;
24004        var top = Math.random() * 88 + 6;
24005        var dur = (Math.random() * 10 + 9).toFixed(1);
24006        var delay = (Math.random() * 18).toFixed(1);
24007        var rot = (Math.random() * 26 - 13).toFixed(1);
24008        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24009        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
24010        container.appendChild(el);
24011      })(i);
24012    }
24013  })();
24014})();
24015</script>
24016</body>
24017</html>
24018"##,
24019    ext = "html"
24020)]
24021pub(crate) struct LoginTemplate {
24022    pub(crate) csp_nonce: String,
24023    pub(crate) has_error: bool,
24024    pub(crate) next_url: String,
24025    pub(crate) lockout_threshold: u32,
24026}
24027
24028// ── REST API reference page ────────────────────────────────────────────────────
24029
24030#[derive(Template)]
24031#[template(
24032    source = r##"
24033<!doctype html>
24034<html lang="en">
24035<head>
24036  <meta charset="utf-8">
24037  <meta name="viewport" content="width=device-width, initial-scale=1">
24038  <title>OxideSLOC — REST API Reference</title>
24039  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24040  <style nonce="{{ csp_nonce }}">
24041    :root {
24042      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24043      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24044      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24045      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24046      --success:#16a34a;
24047    }
24048    body.dark-theme {
24049      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
24050      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
24051    }
24052    *{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;}
24053    .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);}
24054    .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;}
24055    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24056    .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));}
24057    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
24058    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24059    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
24060    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
24061    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24062    @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; } }
24063    .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;}
24064    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
24065    .nav-pill.active{background:rgba(255,255,255,0.22);}
24066    .nav-dropdown{position:relative;display:inline-flex;}
24067    .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;}
24068    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
24069    .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;}
24070    .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;}
24071    .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);}
24072    .nav-dropdown-menu a:last-child{border-bottom:none;}
24073    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
24074    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
24075    .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;}
24076    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24077    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24078    .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;}
24079    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24080    .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);}
24081    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
24082    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
24083    .settings-modal-body{padding:14px 16px 16px;}
24084    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24085    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24086    .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;}
24087    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24088    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24089    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24090    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24091    .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;}
24092    .tz-select:focus{border-color:var(--oxide);}
24093    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
24094    .page-header{margin-bottom:28px;}
24095    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
24096    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
24097    .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;}
24098    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
24099    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
24100    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
24101    .callout strong{font-weight:800;}
24102    .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;}
24103    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
24104    .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;}
24105    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
24106    .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;}
24107    body.dark-theme .base-url-value{color:var(--accent);}
24108    .section{margin-bottom:36px;}
24109    .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);}
24110    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
24111    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
24112    .ep-header:hover{background:var(--surface-2);}
24113    .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;}
24114    .method.get{background:#dcfce7;color:#166534;}
24115    .method.post{background:#dbeafe;color:#1e40af;}
24116    .method.delete{background:#fee2e2;color:#991b1b;}
24117    body.dark-theme .method.get{background:#14532d;color:#86efac;}
24118    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
24119    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
24120    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
24121    .ep-path .param{color:var(--oxide-2);}
24122    body.dark-theme .ep-path .param{color:var(--oxide);}
24123    .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;}
24124    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
24125    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
24126    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
24127    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
24128    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
24129    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
24130    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
24131    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
24132    .ep-card.open .chevron{transform:rotate(180deg);}
24133    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
24134    .ep-card.open .ep-body{display:block;}
24135    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
24136    .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;}
24137    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
24138    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
24139    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
24140    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
24141    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);}
24142    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
24143    table.params tr:last-child td{border-bottom:none;}
24144    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
24145    .pt-type{color:var(--muted-2);font-size:12px;}
24146    .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;}
24147    .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;}
24148    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
24149    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
24150    details.schema{margin-bottom:14px;}
24151    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;}
24152    details.schema summary:hover{color:var(--text);}
24153    .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;}
24154    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
24155    .curl-wrap{position:relative;}
24156    .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;}
24157    .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;}
24158    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
24159    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
24160    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
24161    .webhook-note a{color:var(--accent-2);text-decoration:none;}
24162    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24163    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24164    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24165    .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;}
24166    @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));}}
24167    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24168    .site-footer a{color:var(--muted);}
24169  </style>
24170</head>
24171<body>
24172  <div class="background-watermarks" aria-hidden="true">
24173    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24174    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24175    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24176    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24177    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24178    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24179    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24180  </div>
24181  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24182  <div class="top-nav">
24183    <div class="top-nav-inner">
24184      <a class="brand" href="/">
24185        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24186        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
24187      </a>
24188      <div class="nav-right">
24189        <a class="nav-pill" href="/">Home</a>
24190        <div class="nav-dropdown">
24191          <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>
24192          <div class="nav-dropdown-menu">
24193            <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>
24194          </div>
24195        </div>
24196        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24197        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24198        <div class="nav-dropdown">
24199          <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>
24200          <div class="nav-dropdown-menu">
24201            <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>
24202          </div>
24203        </div>
24204        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24205          <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>
24206        </button>
24207        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24208          <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>
24209          <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>
24210        </button>
24211      </div>
24212    </div>
24213  </div>
24214
24215  <div class="page">
24216    <div class="page-header">
24217      <h1 class="page-title">REST API Reference</h1>
24218      <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>
24219    </div>
24220
24221    {% if has_api_key %}
24222    <div class="callout key-set">
24223      <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>
24224      <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>
24225    </div>
24226    {% else %}
24227    <div class="callout no-key">
24228      <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>
24229      <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>
24230    </div>
24231    {% endif %}
24232
24233    <div class="base-url-bar">
24234      <span class="base-url-label">Base URL</span>
24235      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
24236    </div>
24237
24238    <!-- Health -->
24239    <div class="section">
24240      <h2 class="section-title">Health &amp; Status</h2>
24241      <div class="ep-card">
24242        <div class="ep-header">
24243          <span class="method get">GET</span>
24244          <span class="ep-path">/healthz</span>
24245          <span class="auth-badge public">Public</span>
24246          <span class="ep-desc">Server liveness check</span>
24247          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24248        </div>
24249        <div class="ep-body">
24250          <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>
24251          <p class="params-heading">Response</p>
24252          <div class="schema-block">200 OK
24253Content-Type: text/plain
24254
24255ok</div>
24256          <p class="curl-heading">Example</p>
24257          <div class="curl-wrap">
24258            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
24259            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
24260          </div>
24261        </div>
24262      </div>
24263    </div>
24264
24265    <!-- Badges -->
24266    <div class="section">
24267      <h2 class="section-title">Badges</h2>
24268      <div class="ep-card">
24269        <div class="ep-header">
24270          <span class="method get">GET</span>
24271          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
24272          <span class="auth-badge public">Public</span>
24273          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
24274          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24275        </div>
24276        <div class="ep-body">
24277          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
24278          <p class="params-heading">Path Parameters</p>
24279          <table class="params">
24280            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24281            <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>
24282          </table>
24283          <p class="curl-heading">Example</p>
24284          <div class="curl-wrap">
24285            <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>
24286            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
24287          </div>
24288        </div>
24289      </div>
24290    </div>
24291
24292    <!-- Metrics -->
24293    <div class="section">
24294      <h2 class="section-title">Metrics</h2>
24295
24296      <div class="ep-card">
24297        <div class="ep-header">
24298          <span class="method get">GET</span>
24299          <span class="ep-path">/api/metrics/latest</span>
24300          <span class="auth-badge protected">Protected</span>
24301          <span class="ep-desc">Latest scan metrics (JSON)</span>
24302          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24303        </div>
24304        <div class="ep-body">
24305          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
24306          <details class="schema"><summary>Response schema</summary>
24307<div class="schema-block">{
24308  "run_id":    string,        // UUID
24309  "timestamp": string,        // ISO-8601 UTC
24310  "project":   string,        // scanned root path
24311  "summary": {
24312    "files_analyzed":       number,
24313    "files_skipped":        number,
24314    "code_lines":           number,
24315    "comment_lines":        number,
24316    "blank_lines":          number,
24317    "total_physical_lines": number,
24318    "functions":            number,
24319    "classes":              number,
24320    "variables":            number,
24321    "imports":              number
24322  },
24323  "languages": [
24324    { "name": string, "files": number, "code_lines": number,
24325      "comment_lines": number, "blank_lines": number,
24326      "functions": number, "classes": number,
24327      "variables": number, "imports": number }
24328  ]
24329}</div></details>
24330          <p class="curl-heading">Example</p>
24331          <div class="curl-wrap">
24332            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24333  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
24334            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
24335          </div>
24336        </div>
24337      </div>
24338
24339      <div class="ep-card">
24340        <div class="ep-header">
24341          <span class="method get">GET</span>
24342          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
24343          <span class="auth-badge protected">Protected</span>
24344          <span class="ep-desc">Metrics for a specific run</span>
24345          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24346        </div>
24347        <div class="ep-body">
24348          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
24349          <p class="params-heading">Path Parameters</p>
24350          <table class="params">
24351            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24352            <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>
24353          </table>
24354          <p class="curl-heading">Example</p>
24355          <div class="curl-wrap">
24356            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24357  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
24358            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
24359          </div>
24360        </div>
24361      </div>
24362
24363      <div class="ep-card">
24364        <div class="ep-header">
24365          <span class="method get">GET</span>
24366          <span class="ep-path">/api/metrics/history</span>
24367          <span class="auth-badge protected">Protected</span>
24368          <span class="ep-desc">Paginated scan history</span>
24369          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24370        </div>
24371        <div class="ep-body">
24372          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
24373          <p class="params-heading">Query Parameters</p>
24374          <table class="params">
24375            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24376            <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>
24377            <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>
24378          </table>
24379          <details class="schema"><summary>Response schema</summary>
24380<div class="schema-block">[{
24381  "run_id":         string,
24382  "timestamp":      string,   // ISO-8601 UTC
24383  "commit":         string | null,
24384  "branch":         string | null,
24385  "tags":           string[],
24386  "code_lines":     number,
24387  "comment_lines":  number,
24388  "blank_lines":    number,
24389  "physical_lines": number,
24390  "files_analyzed": number,
24391  "project_label":  string,
24392  "html_url":       string | null
24393}]</div></details>
24394          <p class="curl-heading">Example</p>
24395          <div class="curl-wrap">
24396            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24397  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
24398            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
24399          </div>
24400        </div>
24401      </div>
24402
24403      <div class="ep-card">
24404        <div class="ep-header">
24405          <span class="method get">GET</span>
24406          <span class="ep-path">/api/project-history</span>
24407          <span class="auth-badge protected">Protected</span>
24408          <span class="ep-desc">Project-level scan summary</span>
24409          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24410        </div>
24411        <div class="ep-body">
24412          <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>
24413          <p class="params-heading">Query Parameters</p>
24414          <table class="params">
24415            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24416            <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>
24417          </table>
24418          <details class="schema"><summary>Response schema</summary>
24419<div class="schema-block">{
24420  "scan_count":           number,
24421  "last_scan_id":         string | null,
24422  "last_scan_timestamp":  string | null,  // ISO-8601
24423  "last_scan_code_lines": number | null,
24424  "last_git_branch":      string | null,
24425  "last_git_commit":      string | null
24426}</div></details>
24427          <p class="curl-heading">Example</p>
24428          <div class="curl-wrap">
24429            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24430  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
24431            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
24432          </div>
24433        </div>
24434      </div>
24435
24436      <div class="ep-card">
24437        <div class="ep-header">
24438          <span class="method get">GET</span>
24439          <span class="ep-path">/api/metrics/submodules</span>
24440          <span class="auth-badge protected">Protected</span>
24441          <span class="ep-desc">List known git submodules across scans</span>
24442          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24443        </div>
24444        <div class="ep-body">
24445          <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>
24446          <p class="params-heading">Query Parameters</p>
24447          <table class="params">
24448            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24449            <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>
24450          </table>
24451          <details class="schema"><summary>Response schema</summary>
24452<div class="schema-block">[{
24453  "name":          string,  // submodule name
24454  "relative_path": string   // path relative to the project root
24455}]</div></details>
24456          <p class="curl-heading">Example</p>
24457          <div class="curl-wrap">
24458            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24459  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
24460            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
24461          </div>
24462        </div>
24463      </div>
24464    </div>
24465
24466    <!-- Async Run Status -->
24467    <div class="section">
24468      <h2 class="section-title">Async Run Status</h2>
24469
24470      <div class="ep-card">
24471        <div class="ep-header">
24472          <span class="method get">GET</span>
24473          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
24474          <span class="auth-badge protected">Protected</span>
24475          <span class="ep-desc">Poll scan completion</span>
24476          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24477        </div>
24478        <div class="ep-body">
24479          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
24480          <details class="schema"><summary>Response schema</summary>
24481<div class="schema-block">// Running
24482{ "state": "running",  "elapsed_secs": number }
24483
24484// Complete
24485{ "state": "complete", "run_id": string }
24486
24487// Failed
24488{ "state": "failed",   "message": string }</div></details>
24489          <p class="curl-heading">Example</p>
24490          <div class="curl-wrap">
24491            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24492  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
24493            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
24494          </div>
24495        </div>
24496      </div>
24497
24498      <div class="ep-card">
24499        <div class="ep-header">
24500          <span class="method get">GET</span>
24501          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
24502          <span class="auth-badge protected">Protected</span>
24503          <span class="ep-desc">Poll PDF generation readiness</span>
24504          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24505        </div>
24506        <div class="ep-body">
24507          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
24508          <details class="schema"><summary>Response schema</summary>
24509<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
24510          <p class="curl-heading">Example</p>
24511          <div class="curl-wrap">
24512            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24513  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
24514            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
24515          </div>
24516        </div>
24517      </div>
24518
24519      <div class="ep-card">
24520        <div class="ep-header">
24521          <span class="method post">POST</span>
24522          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
24523          <span class="auth-badge protected">Protected</span>
24524          <span class="ep-desc">Cancel a running scan</span>
24525          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24526        </div>
24527        <div class="ep-body">
24528          <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>
24529          <p class="curl-heading">Example</p>
24530          <div class="curl-wrap">
24531            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
24532  -H "Authorization: Bearer $SLOC_API_KEY" \
24533  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
24534            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
24535          </div>
24536        </div>
24537      </div>
24538    </div>
24539
24540    <!-- Run Management -->
24541    <div class="section">
24542      <h2 class="section-title">Run Management</h2>
24543
24544      <div class="ep-card">
24545        <div class="ep-header">
24546          <span class="method get">GET</span>
24547          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
24548          <span class="auth-badge protected">Protected</span>
24549          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
24550          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24551        </div>
24552        <div class="ep-body">
24553          <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>
24554          <p class="params-heading">Path Parameters</p>
24555          <table class="params">
24556            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24557            <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>
24558          </table>
24559          <details class="schema"><summary>Response</summary>
24560<div class="schema-block">200 OK — Content-Type: application/zip
24561Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
24562
24563404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
24564          <p class="curl-heading">Example</p>
24565          <div class="curl-wrap">
24566            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24567  -o run.zip \
24568  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
24569            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
24570          </div>
24571        </div>
24572      </div>
24573
24574      <div class="ep-card">
24575        <div class="ep-header">
24576          <span class="method delete">DELETE</span>
24577          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
24578          <span class="auth-badge protected">Protected</span>
24579          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
24580          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24581        </div>
24582        <div class="ep-body">
24583          <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>
24584          <p class="params-heading">Path Parameters</p>
24585          <table class="params">
24586            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24587            <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>
24588          </table>
24589          <details class="schema"><summary>Response</summary>
24590<div class="schema-block">204 No Content — run successfully deleted
24591
24592500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
24593          <p class="curl-heading">Example</p>
24594          <div class="curl-wrap">
24595            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
24596  -H "Authorization: Bearer $SLOC_API_KEY" \
24597  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
24598            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
24599          </div>
24600        </div>
24601      </div>
24602
24603      <div class="ep-card">
24604        <div class="ep-header">
24605          <span class="method post">POST</span>
24606          <span class="ep-path">/api/runs/cleanup</span>
24607          <span class="auth-badge protected">Protected</span>
24608          <span class="ep-desc">Bulk delete runs older than N days</span>
24609          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24610        </div>
24611        <div class="ep-body">
24612          <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>
24613          <p class="params-heading">Request Body (application/json)</p>
24614          <table class="params">
24615            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24616            <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>
24617          </table>
24618          <details class="schema"><summary>Response schema</summary>
24619<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
24620          <p class="curl-heading">Example — delete runs older than 60 days</p>
24621          <div class="curl-wrap">
24622            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
24623  -H "Authorization: Bearer $SLOC_API_KEY" \
24624  -H "Content-Type: application/json" \
24625  -d '{"older_than_days":60}' \
24626  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
24627            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
24628          </div>
24629        </div>
24630      </div>
24631    </div>
24632
24633    <!-- Retention Policy -->
24634    <div class="section">
24635      <h2 class="section-title">Retention Policy</h2>
24636
24637      <div class="ep-card">
24638        <div class="ep-header">
24639          <span class="method get">GET</span>
24640          <span class="ep-path">/api/cleanup-policy</span>
24641          <span class="auth-badge protected">Protected</span>
24642          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
24643          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24644        </div>
24645        <div class="ep-body">
24646          <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>
24647          <details class="schema"><summary>Response schema</summary>
24648<div class="schema-block">{
24649  "policy": {
24650    "enabled":       boolean,
24651    "max_age_days":  number | null,   // delete runs older than N days
24652    "max_run_count": number | null,   // keep only the N most recent runs
24653    "interval_hours": number          // hours between background passes
24654  } | null,
24655  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
24656  "last_run_deleted": number | null   // runs deleted in last pass
24657}</div></details>
24658          <p class="curl-heading">Example</p>
24659          <div class="curl-wrap">
24660            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24661  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24662            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
24663          </div>
24664        </div>
24665      </div>
24666
24667      <div class="ep-card">
24668        <div class="ep-header">
24669          <span class="method post">POST</span>
24670          <span class="ep-path">/api/cleanup-policy</span>
24671          <span class="auth-badge protected">Protected</span>
24672          <span class="ep-desc">Save or update the retention policy</span>
24673          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24674        </div>
24675        <div class="ep-body">
24676          <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>
24677          <p class="params-heading">Request Body (application/json)</p>
24678          <table class="params">
24679            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24680            <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>
24681            <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>
24682            <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>
24683            <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>
24684          </table>
24685          <details class="schema"><summary>Response</summary>
24686<div class="schema-block">204 No Content — policy saved and task (re)started
24687
24688500 Internal Server Error — { "error": string }</div></details>
24689          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
24690          <div class="curl-wrap">
24691            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
24692  -H "Authorization: Bearer $SLOC_API_KEY" \
24693  -H "Content-Type: application/json" \
24694  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
24695  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24696            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
24697          </div>
24698        </div>
24699      </div>
24700
24701      <div class="ep-card">
24702        <div class="ep-header">
24703          <span class="method post">POST</span>
24704          <span class="ep-path">/api/cleanup-policy/run-now</span>
24705          <span class="auth-badge protected">Protected</span>
24706          <span class="ep-desc">Trigger an immediate cleanup pass</span>
24707          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24708        </div>
24709        <div class="ep-body">
24710          <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>
24711          <details class="schema"><summary>Response schema</summary>
24712<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
24713          <p class="curl-heading">Example</p>
24714          <div class="curl-wrap">
24715            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
24716  -H "Authorization: Bearer $SLOC_API_KEY" \
24717  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
24718            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
24719          </div>
24720        </div>
24721      </div>
24722
24723      <div class="ep-card">
24724        <div class="ep-header">
24725          <span class="method delete">DELETE</span>
24726          <span class="ep-path">/api/cleanup-policy</span>
24727          <span class="auth-badge protected">Protected</span>
24728          <span class="ep-desc">Remove the retention policy and stop the background task</span>
24729          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24730        </div>
24731        <div class="ep-body">
24732          <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>
24733          <details class="schema"><summary>Response</summary>
24734<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
24735          <p class="curl-heading">Example</p>
24736          <div class="curl-wrap">
24737            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
24738  -H "Authorization: Bearer $SLOC_API_KEY" \
24739  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24740            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
24741          </div>
24742        </div>
24743      </div>
24744    </div>
24745
24746    <!-- Scan Profiles -->
24747    <div class="section">
24748      <h2 class="section-title">Scan Profiles</h2>
24749
24750      <div class="ep-card">
24751        <div class="ep-header">
24752          <span class="method get">GET</span>
24753          <span class="ep-path">/api/scan-profiles</span>
24754          <span class="auth-badge protected">Protected</span>
24755          <span class="ep-desc">List saved scan profiles</span>
24756          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24757        </div>
24758        <div class="ep-body">
24759          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
24760          <details class="schema"><summary>Response schema</summary>
24761<div class="schema-block">{
24762  "profiles": [{
24763    "id":         string,   // UUID
24764    "name":       string,
24765    "created_at": string,   // ISO-8601
24766    "params":     object
24767  }]
24768}</div></details>
24769          <p class="curl-heading">Example</p>
24770          <div class="curl-wrap">
24771            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24772  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24773            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
24774          </div>
24775        </div>
24776      </div>
24777
24778      <div class="ep-card">
24779        <div class="ep-header">
24780          <span class="method post">POST</span>
24781          <span class="ep-path">/api/scan-profiles</span>
24782          <span class="auth-badge protected">Protected</span>
24783          <span class="ep-desc">Save a scan profile</span>
24784          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24785        </div>
24786        <div class="ep-body">
24787          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
24788          <p class="params-heading">Request Body (application/json)</p>
24789          <table class="params">
24790            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24791            <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>
24792            <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>
24793          </table>
24794          <details class="schema"><summary>Response schema</summary>
24795<div class="schema-block">{ "ok": true }</div></details>
24796          <p class="curl-heading">Example</p>
24797          <div class="curl-wrap">
24798            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
24799  -H "Authorization: Bearer $SLOC_API_KEY" \
24800  -H "Content-Type: application/json" \
24801  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
24802  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24803            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
24804          </div>
24805        </div>
24806      </div>
24807
24808      <div class="ep-card">
24809        <div class="ep-header">
24810          <span class="method delete">DELETE</span>
24811          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
24812          <span class="auth-badge protected">Protected</span>
24813          <span class="ep-desc">Delete a scan profile</span>
24814          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24815        </div>
24816        <div class="ep-body">
24817          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
24818          <p class="params-heading">Path Parameters</p>
24819          <table class="params">
24820            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24821            <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>
24822          </table>
24823          <details class="schema"><summary>Response schema</summary>
24824<div class="schema-block">{ "ok": true }</div></details>
24825          <p class="curl-heading">Example</p>
24826          <div class="curl-wrap">
24827            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
24828  -H "Authorization: Bearer $SLOC_API_KEY" \
24829  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
24830            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
24831          </div>
24832        </div>
24833      </div>
24834    </div>
24835
24836    <!-- Scheduled Scans -->
24837    <div class="section">
24838      <h2 class="section-title">Scheduled Scans</h2>
24839
24840      <div class="ep-card">
24841        <div class="ep-header">
24842          <span class="method get">GET</span>
24843          <span class="ep-path">/api/schedules</span>
24844          <span class="auth-badge protected">Protected</span>
24845          <span class="ep-desc">List configured schedules</span>
24846          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24847        </div>
24848        <div class="ep-body">
24849          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
24850          <p class="curl-heading">Example</p>
24851          <div class="curl-wrap">
24852            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24853  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24854            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
24855          </div>
24856        </div>
24857      </div>
24858
24859      <div class="ep-card">
24860        <div class="ep-header">
24861          <span class="method post">POST</span>
24862          <span class="ep-path">/api/schedules</span>
24863          <span class="auth-badge protected">Protected</span>
24864          <span class="ep-desc">Create a schedule</span>
24865          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24866        </div>
24867        <div class="ep-body">
24868          <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>
24869          <p class="curl-heading">Example</p>
24870          <div class="curl-wrap">
24871            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
24872  -H "Authorization: Bearer $SLOC_API_KEY" \
24873  -H "Content-Type: application/json" \
24874  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
24875  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24876            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
24877          </div>
24878        </div>
24879      </div>
24880
24881      <div class="ep-card">
24882        <div class="ep-header">
24883          <span class="method delete">DELETE</span>
24884          <span class="ep-path">/api/schedules</span>
24885          <span class="auth-badge protected">Protected</span>
24886          <span class="ep-desc">Delete a schedule</span>
24887          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24888        </div>
24889        <div class="ep-body">
24890          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
24891          <p class="curl-heading">Example</p>
24892          <div class="curl-wrap">
24893            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
24894  -H "Authorization: Bearer $SLOC_API_KEY" \
24895  -H "Content-Type: application/json" \
24896  -d '{"id":"&lt;schedule_id&gt;"}' \
24897  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24898            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
24899          </div>
24900        </div>
24901      </div>
24902    </div>
24903
24904    <!-- Git Browser -->
24905    <div class="section">
24906      <h2 class="section-title">Git Browser</h2>
24907
24908      <div class="ep-card">
24909        <div class="ep-header">
24910          <span class="method get">GET</span>
24911          <span class="ep-path">/api/git/refs</span>
24912          <span class="auth-badge protected">Protected</span>
24913          <span class="ep-desc">List git refs for a repository</span>
24914          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24915        </div>
24916        <div class="ep-body">
24917          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
24918          <p class="params-heading">Query Parameters</p>
24919          <table class="params">
24920            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24921            <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>
24922          </table>
24923          <p class="curl-heading">Example</p>
24924          <div class="curl-wrap">
24925            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24926  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
24927            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
24928          </div>
24929        </div>
24930      </div>
24931
24932      <div class="ep-card">
24933        <div class="ep-header">
24934          <span class="method get">GET</span>
24935          <span class="ep-path">/api/git/scan-ref</span>
24936          <span class="auth-badge protected">Protected</span>
24937          <span class="ep-desc">SLOC-scan a specific git ref</span>
24938          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24939        </div>
24940        <div class="ep-body">
24941          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
24942          <p class="params-heading">Query Parameters</p>
24943          <table class="params">
24944            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24945            <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>
24946            <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>
24947          </table>
24948          <p class="curl-heading">Example</p>
24949          <div class="curl-wrap">
24950            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24951  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
24952            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
24953          </div>
24954        </div>
24955      </div>
24956
24957      <div class="ep-card">
24958        <div class="ep-header">
24959          <span class="method get">GET</span>
24960          <span class="ep-path">/api/git/compare-refs</span>
24961          <span class="auth-badge protected">Protected</span>
24962          <span class="ep-desc">Compare SLOC across two git refs</span>
24963          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24964        </div>
24965        <div class="ep-body">
24966          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
24967          <p class="params-heading">Query Parameters</p>
24968          <table class="params">
24969            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24970            <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>
24971            <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>
24972            <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>
24973          </table>
24974          <p class="curl-heading">Example</p>
24975          <div class="curl-wrap">
24976            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24977  "<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>
24978            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
24979          </div>
24980        </div>
24981      </div>
24982    </div>
24983
24984    <!-- Webhooks -->
24985    <div class="section">
24986      <h2 class="section-title">Webhooks</h2>
24987      <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>
24988
24989      <div class="ep-card">
24990        <div class="ep-header">
24991          <span class="method post">POST</span>
24992          <span class="ep-path">/webhooks/github</span>
24993          <span class="auth-badge hmac">HMAC</span>
24994          <span class="ep-desc">GitHub push event receiver</span>
24995          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24996        </div>
24997        <div class="ep-body">
24998          <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>
24999          <p class="params-heading">Required Headers</p>
25000          <table class="params">
25001            <tr><th>Header</th><th>Value</th></tr>
25002            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
25003            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
25004            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25005          </table>
25006        </div>
25007      </div>
25008
25009      <div class="ep-card">
25010        <div class="ep-header">
25011          <span class="method post">POST</span>
25012          <span class="ep-path">/webhooks/gitlab</span>
25013          <span class="auth-badge hmac">HMAC</span>
25014          <span class="ep-desc">GitLab push event receiver</span>
25015          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25016        </div>
25017        <div class="ep-body">
25018          <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>
25019          <p class="params-heading">Required Headers</p>
25020          <table class="params">
25021            <tr><th>Header</th><th>Value</th></tr>
25022            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
25023            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
25024            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25025          </table>
25026        </div>
25027      </div>
25028
25029      <div class="ep-card">
25030        <div class="ep-header">
25031          <span class="method post">POST</span>
25032          <span class="ep-path">/webhooks/bitbucket</span>
25033          <span class="auth-badge hmac">HMAC</span>
25034          <span class="ep-desc">Bitbucket push event receiver</span>
25035          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25036        </div>
25037        <div class="ep-body">
25038          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
25039          <p class="params-heading">Required Headers</p>
25040          <table class="params">
25041            <tr><th>Header</th><th>Value</th></tr>
25042            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
25043            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25044          </table>
25045        </div>
25046      </div>
25047    </div>
25048
25049    <!-- Config -->
25050    <div class="section">
25051      <h2 class="section-title">Config Import / Export</h2>
25052
25053      <div class="ep-card">
25054        <div class="ep-header">
25055          <span class="method get">GET</span>
25056          <span class="ep-path">/export-config</span>
25057          <span class="auth-badge protected">Protected</span>
25058          <span class="ep-desc">Export server configuration as JSON</span>
25059          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25060        </div>
25061        <div class="ep-body">
25062          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
25063          <p class="curl-heading">Example</p>
25064          <div class="curl-wrap">
25065            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25066  -o config.json \
25067  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
25068            <button class="curl-copy-btn" data-target="c-export">Copy</button>
25069          </div>
25070        </div>
25071      </div>
25072
25073      <div class="ep-card">
25074        <div class="ep-header">
25075          <span class="method post">POST</span>
25076          <span class="ep-path">/import-config</span>
25077          <span class="auth-badge protected">Protected</span>
25078          <span class="ep-desc">Import server configuration</span>
25079          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25080        </div>
25081        <div class="ep-body">
25082          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
25083          <p class="curl-heading">Example</p>
25084          <div class="curl-wrap">
25085            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
25086  -H "Authorization: Bearer $SLOC_API_KEY" \
25087  -H "Content-Type: application/json" \
25088  -d @config.json \
25089  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
25090            <button class="curl-copy-btn" data-target="c-import">Copy</button>
25091          </div>
25092        </div>
25093      </div>
25094    </div>
25095
25096    <!-- CI Ingest -->
25097    <div class="section">
25098      <h2 class="section-title">CI Ingest</h2>
25099
25100      <div class="ep-card">
25101        <div class="ep-header">
25102          <span class="method post">POST</span>
25103          <span class="ep-path">/api/ingest</span>
25104          <span class="auth-badge protected">Protected</span>
25105          <span class="ep-desc">Push a pre-computed scan result from CI</span>
25106          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25107        </div>
25108        <div class="ep-body">
25109          <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>
25110          <p class="params-heading">Query Parameters</p>
25111          <table class="params">
25112            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25113            <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>
25114          </table>
25115          <p class="params-heading">Request Body (application/json)</p>
25116          <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>
25117          <details class="schema"><summary>Response schema</summary>
25118<div class="schema-block">// 201 Created
25119{
25120  "run_id":   string,  // UUID of the ingested run
25121  "view_url": string   // relative URL to the report page
25122}</div></details>
25123          <p class="curl-heading">Example</p>
25124          <div class="curl-wrap">
25125            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
25126  -H "Authorization: Bearer $SLOC_API_KEY" \
25127  -H "Content-Type: application/json" \
25128  -d @result.json \
25129  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
25130            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
25131          </div>
25132        </div>
25133      </div>
25134    </div>
25135
25136    <!-- Artifact Download -->
25137    <div class="section">
25138      <h2 class="section-title">Artifact Download</h2>
25139
25140      <div class="ep-card">
25141        <div class="ep-header">
25142          <span class="method get">GET</span>
25143          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
25144          <span class="auth-badge protected">Protected</span>
25145          <span class="ep-desc">Download or view a scan artifact</span>
25146          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25147        </div>
25148        <div class="ep-body">
25149          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
25150          <p class="params-heading">Path Parameters</p>
25151          <table class="params">
25152            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25153            <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>
25154            <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>
25155          </table>
25156          <p class="params-heading">Query Parameters</p>
25157          <table class="params">
25158            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25159            <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>
25160          </table>
25161          <p class="curl-heading">Example — download JSON result</p>
25162          <div class="curl-wrap">
25163            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25164  -o result.json \
25165  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
25166            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
25167          </div>
25168        </div>
25169      </div>
25170    </div>
25171
25172    <!-- Embed Widget -->
25173    <div class="section">
25174      <h2 class="section-title">Embed Widget</h2>
25175
25176      <div class="ep-card">
25177        <div class="ep-header">
25178          <span class="method get">GET</span>
25179          <span class="ep-path">/embed/summary</span>
25180          <span class="auth-badge protected">Protected</span>
25181          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
25182          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25183        </div>
25184        <div class="ep-body">
25185          <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>
25186          <p class="params-heading">Query Parameters</p>
25187          <table class="params">
25188            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25189            <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>
25190            <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>
25191          </table>
25192          <p class="curl-heading">Example</p>
25193          <div class="curl-wrap">
25194            <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"
25195        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
25196            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
25197          </div>
25198        </div>
25199      </div>
25200    </div>
25201
25202    <!-- Confluence Integration -->
25203    <div class="section">
25204      <h2 class="section-title">Confluence Integration</h2>
25205
25206      <div class="ep-card">
25207        <div class="ep-header">
25208          <span class="method get">GET</span>
25209          <span class="ep-path">/api/confluence/config</span>
25210          <span class="auth-badge protected">Protected</span>
25211          <span class="ep-desc">Get current Confluence configuration</span>
25212          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25213        </div>
25214        <div class="ep-body">
25215          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
25216          <details class="schema"><summary>Response schema</summary>
25217<div class="schema-block">{
25218  "configured":     boolean,
25219  "tier":           "cloud" | "server",
25220  "base_url":       string,
25221  "username":       string,
25222  "api_token_set":  boolean,
25223  "space_key":      string,
25224  "parent_page_id": string | null,
25225  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
25226}</div></details>
25227          <p class="curl-heading">Example</p>
25228          <div class="curl-wrap">
25229            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25230  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
25231            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
25232          </div>
25233        </div>
25234      </div>
25235
25236      <div class="ep-card">
25237        <div class="ep-header">
25238          <span class="method post">POST</span>
25239          <span class="ep-path">/api/confluence/config</span>
25240          <span class="auth-badge protected">Protected</span>
25241          <span class="ep-desc">Save Confluence configuration</span>
25242          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25243        </div>
25244        <div class="ep-body">
25245          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
25246          <p class="params-heading">Request Body (application/json)</p>
25247          <table class="params">
25248            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25249            <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>
25250            <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>
25251            <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>
25252            <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>
25253            <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>
25254            <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>
25255            <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>
25256          </table>
25257          <details class="schema"><summary>Response schema</summary>
25258<div class="schema-block">{ "ok": true }</div></details>
25259          <p class="curl-heading">Example</p>
25260          <div class="curl-wrap">
25261            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
25262  -H "Authorization: Bearer $SLOC_API_KEY" \
25263  -H "Content-Type: application/json" \
25264  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
25265  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
25266            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
25267          </div>
25268        </div>
25269      </div>
25270
25271      <div class="ep-card">
25272        <div class="ep-header">
25273          <span class="method post">POST</span>
25274          <span class="ep-path">/api/confluence/test</span>
25275          <span class="auth-badge protected">Protected</span>
25276          <span class="ep-desc">Test Confluence connection</span>
25277          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25278        </div>
25279        <div class="ep-body">
25280          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
25281          <details class="schema"><summary>Response schema</summary>
25282<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
25283          <p class="curl-heading">Example</p>
25284          <div class="curl-wrap">
25285            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
25286  -H "Authorization: Bearer $SLOC_API_KEY" \
25287  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
25288            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
25289          </div>
25290        </div>
25291      </div>
25292
25293      <div class="ep-card">
25294        <div class="ep-header">
25295          <span class="method post">POST</span>
25296          <span class="ep-path">/api/confluence/post</span>
25297          <span class="auth-badge protected">Protected</span>
25298          <span class="ep-desc">Publish a scan report to Confluence</span>
25299          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25300        </div>
25301        <div class="ep-body">
25302          <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>
25303          <p class="params-heading">Request Body (application/json)</p>
25304          <table class="params">
25305            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25306            <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>
25307            <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>
25308            <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>
25309          </table>
25310          <details class="schema"><summary>Response schema</summary>
25311<div class="schema-block">// 200 OK
25312{ "ok": true, "page_id": string }
25313
25314// 400 / 502 on error
25315{ "ok": false, "error": string }</div></details>
25316          <p class="curl-heading">Example</p>
25317          <div class="curl-wrap">
25318            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
25319  -H "Authorization: Bearer $SLOC_API_KEY" \
25320  -H "Content-Type: application/json" \
25321  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
25322  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
25323            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
25324          </div>
25325        </div>
25326      </div>
25327
25328      <div class="ep-card">
25329        <div class="ep-header">
25330          <span class="method get">GET</span>
25331          <span class="ep-path">/api/confluence/wiki-markup</span>
25332          <span class="auth-badge protected">Protected</span>
25333          <span class="ep-desc">Get Confluence wiki markup for a run</span>
25334          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25335        </div>
25336        <div class="ep-body">
25337          <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>
25338          <p class="params-heading">Query Parameters</p>
25339          <table class="params">
25340            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25341            <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>
25342          </table>
25343          <p class="curl-heading">Example</p>
25344          <div class="curl-wrap">
25345            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25346  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
25347            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
25348          </div>
25349        </div>
25350      </div>
25351    </div>
25352
25353    <!-- Authentication -->
25354    <div class="section">
25355      <h2 class="section-title">Authentication</h2>
25356      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
25357
25358      <div class="ep-card">
25359        <div class="ep-header">
25360          <span class="method get">GET</span>
25361          <span class="ep-path">/auth/login</span>
25362          <span class="auth-badge public">Public</span>
25363          <span class="ep-desc">Login page</span>
25364          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25365        </div>
25366        <div class="ep-body">
25367          <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>
25368          <p class="params-heading">Query Parameters</p>
25369          <table class="params">
25370            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25371            <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>
25372            <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>
25373          </table>
25374        </div>
25375      </div>
25376
25377      <div class="ep-card">
25378        <div class="ep-header">
25379          <span class="method post">POST</span>
25380          <span class="ep-path">/auth/login</span>
25381          <span class="auth-badge public">Public</span>
25382          <span class="ep-desc">Submit credentials and get a session cookie</span>
25383          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25384        </div>
25385        <div class="ep-body">
25386          <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>
25387          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
25388          <table class="params">
25389            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25390            <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>
25391            <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>
25392          </table>
25393          <p class="curl-heading">Example</p>
25394          <div class="curl-wrap">
25395            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
25396  -d "key=$SLOC_API_KEY&amp;next=/" \
25397  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
25398            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
25399          </div>
25400        </div>
25401      </div>
25402    </div>
25403
25404    <!-- Coverage Suggestion -->
25405    <div class="section">
25406      <h2 class="section-title">Coverage Suggestion</h2>
25407
25408      <div class="ep-card">
25409        <div class="ep-header">
25410          <span class="method get">GET</span>
25411          <span class="ep-path">/api/suggest-coverage</span>
25412          <span class="auth-badge protected">Protected</span>
25413          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
25414          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25415        </div>
25416        <div class="ep-body">
25417          <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>
25418          <p class="params-heading">Query Parameters</p>
25419          <table class="params">
25420            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25421            <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>
25422          </table>
25423          <details class="schema"><summary>Response schema</summary>
25424<div class="schema-block">{
25425  "found": string | null,  // absolute path to the coverage file, if detected
25426  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
25427  "hint":  string | null   // shell command to generate coverage if not found
25428}</div></details>
25429          <p class="curl-heading">Example</p>
25430          <div class="curl-wrap">
25431            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25432  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
25433            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
25434          </div>
25435        </div>
25436      </div>
25437    </div>
25438
25439  </div>
25440
25441  <footer class="site-footer">
25442    local code analysis - metrics, history and reports
25443    &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>
25444    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25445    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25446    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25447    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25448  </footer>
25449
25450  <script nonce="{{ csp_nonce }}">
25451    (function () {
25452      var base = window.location.origin;
25453      document.getElementById('base-url').textContent = base;
25454      document.querySelectorAll('.base-url-slot').forEach(function (el) {
25455        el.textContent = base;
25456      });
25457
25458      document.querySelectorAll('.ep-header').forEach(function (hdr) {
25459        hdr.addEventListener('click', function () {
25460          hdr.closest('.ep-card').classList.toggle('open');
25461        });
25462      });
25463
25464      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
25465        btn.addEventListener('click', function () {
25466          var targetId = btn.dataset.target;
25467          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
25468          if (!pre) return;
25469          navigator.clipboard.writeText(pre.textContent).then(function () {
25470            btn.textContent = 'Copied!';
25471            btn.classList.add('copied');
25472            setTimeout(function () {
25473              btn.textContent = 'Copy';
25474              btn.classList.remove('copied');
25475            }, 2000);
25476          });
25477        });
25478      });
25479
25480      var storageKey = 'oxide-sloc-theme';
25481      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
25482      var themeBtn = document.getElementById('theme-toggle');
25483      if (themeBtn) {
25484        themeBtn.addEventListener('click', function () {
25485          var dark = document.body.classList.toggle('dark-theme');
25486          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
25487        });
25488      }
25489      (function() {
25490        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'}];
25491        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);});}
25492        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25493        var btn=document.getElementById('settings-btn');if(!btn)return;
25494        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25495        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>';
25496        document.body.appendChild(m);
25497        var g=document.getElementById('scheme-grid');
25498        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);});
25499        var cl=document.getElementById('settings-close');
25500        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);
25501        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');});
25502        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25503        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25504      })();
25505      (function randomizeWatermarks() {
25506        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25507        if (!wms.length) return;
25508        var placed = [];
25509        function tooClose(top, left) {
25510          for (var i = 0; i < placed.length; i++) {
25511            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25512            if (dt < 16 && dl < 12) return true;
25513          }
25514          return false;
25515        }
25516        function pick(leftBand) {
25517          for (var attempt = 0; attempt < 50; attempt++) {
25518            var top = Math.random() * 88 + 2;
25519            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25520            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25521          }
25522          var top = Math.random() * 88 + 2;
25523          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25524          placed.push([top, left]); return [top, left];
25525        }
25526        var half = Math.floor(wms.length / 2);
25527        wms.forEach(function (img, i) {
25528          var pos = pick(i < half);
25529          var size = Math.floor(Math.random() * 100 + 120);
25530          var rot = (Math.random() * 360).toFixed(1);
25531          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25532          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;
25533        });
25534      })();
25535      (function spawnCodeParticles() {
25536        var container = document.getElementById('code-particles');
25537        if (!container) return;
25538        var snippets = [
25539          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
25540          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
25541          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
25542          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
25543          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
25544        ];
25545        var count = 38;
25546        for (var i = 0; i < count; i++) {
25547          (function(idx) {
25548            var el = document.createElement('span');
25549            el.className = 'code-particle';
25550            el.textContent = snippets[idx % snippets.length];
25551            var left = Math.random() * 94 + 2;
25552            var top = Math.random() * 88 + 6;
25553            var dur = (Math.random() * 10 + 9).toFixed(1);
25554            var delay = (Math.random() * 18).toFixed(1);
25555            var rot = (Math.random() * 26 - 13).toFixed(1);
25556            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25557            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
25558            container.appendChild(el);
25559          })(i);
25560        }
25561      })();
25562    }());
25563  </script>
25564</body>
25565</html>
25566"##,
25567    ext = "html"
25568)]
25569struct ApiDocsTemplate {
25570    has_api_key: bool,
25571    csp_nonce: String,
25572    version: &'static str,
25573}
25574
25575#[cfg(test)]
25576mod form_config_tests {
25577    use super::*;
25578    use sloc_config::{
25579        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
25580    };
25581
25582    fn blank_form() -> AnalyzeForm {
25583        AnalyzeForm {
25584            path: ".".to_string(),
25585            git_repo: None,
25586            git_ref: None,
25587            mixed_line_policy: None,
25588            python_docstrings_as_comments: None,
25589            generated_file_detection: None,
25590            minified_file_detection: None,
25591            vendor_directory_detection: None,
25592            include_lockfiles: None,
25593            binary_file_behavior: None,
25594            output_dir: None,
25595            report_title: None,
25596            report_header_footer: None,
25597            include_globs: None,
25598            exclude_globs: None,
25599            submodule_breakdown: None,
25600            coverage_file: None,
25601            continuation_line_policy: None,
25602            blank_in_block_comment_policy: None,
25603            count_compiler_directives: None,
25604            style_col_threshold: None,
25605            style_analysis_enabled: None,
25606            style_score_threshold: None,
25607            style_lang_scope: None,
25608        }
25609    }
25610
25611    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
25612        let mut cfg = sloc_config::AppConfig::default();
25613        apply_form_to_config(&mut cfg, form);
25614        cfg
25615    }
25616
25617    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
25618
25619    #[test]
25620    fn python_docstrings_false_when_unchecked() {
25621        // Checkbox absent in form data (unchecked) → field must be false.
25622        let cfg = apply(&blank_form());
25623        assert!(
25624            !cfg.analysis.python_docstrings_as_comments,
25625            "absent python_docstrings_as_comments must map to false"
25626        );
25627    }
25628
25629    #[test]
25630    fn python_docstrings_true_when_checked() {
25631        // Browser sends "on" (no value= attr on the checkbox).
25632        let mut form = blank_form();
25633        form.python_docstrings_as_comments = Some("on".to_string());
25634        let cfg = apply(&form);
25635        assert!(cfg.analysis.python_docstrings_as_comments);
25636    }
25637
25638    #[test]
25639    fn python_docstrings_true_for_any_non_none_value() {
25640        // The handler uses .is_some() — any non-None value means "checked".
25641        let mut form = blank_form();
25642        form.python_docstrings_as_comments = Some("true".to_string());
25643        assert!(apply(&form).analysis.python_docstrings_as_comments);
25644    }
25645
25646    // ── submodule_breakdown (checkbox with value="enabled") ──
25647
25648    #[test]
25649    fn submodule_breakdown_false_when_unchecked() {
25650        let cfg = apply(&blank_form());
25651        assert!(
25652            !cfg.discovery.submodule_breakdown,
25653            "absent submodule_breakdown must map to false"
25654        );
25655    }
25656
25657    #[test]
25658    fn submodule_breakdown_true_when_value_enabled() {
25659        let mut form = blank_form();
25660        form.submodule_breakdown = Some("enabled".to_string());
25661        assert!(apply(&form).discovery.submodule_breakdown);
25662    }
25663
25664    #[test]
25665    fn submodule_breakdown_false_for_wrong_value() {
25666        // If somehow a value other than "enabled" is sent, it must still be false.
25667        let mut form = blank_form();
25668        form.submodule_breakdown = Some("on".to_string());
25669        assert!(
25670            !apply(&form).discovery.submodule_breakdown,
25671            "submodule_breakdown only becomes true for the exact value 'enabled'"
25672        );
25673    }
25674
25675    // ── generated_file_detection (select: "enabled" | "disabled") ──
25676
25677    #[test]
25678    fn generated_detection_true_when_enabled() {
25679        let mut form = blank_form();
25680        form.generated_file_detection = Some("enabled".to_string());
25681        assert!(apply(&form).analysis.generated_file_detection);
25682    }
25683
25684    #[test]
25685    fn generated_detection_false_when_disabled() {
25686        let mut form = blank_form();
25687        form.generated_file_detection = Some("disabled".to_string());
25688        assert!(!apply(&form).analysis.generated_file_detection);
25689    }
25690
25691    #[test]
25692    fn generated_detection_true_when_absent() {
25693        // None != Some("disabled") → true (safe default)
25694        assert!(
25695            apply(&blank_form()).analysis.generated_file_detection,
25696            "absent field must default to true (detection on)"
25697        );
25698    }
25699
25700    // ── minified_file_detection ──
25701
25702    #[test]
25703    fn minified_detection_false_when_disabled() {
25704        let mut form = blank_form();
25705        form.minified_file_detection = Some("disabled".to_string());
25706        assert!(!apply(&form).analysis.minified_file_detection);
25707    }
25708
25709    #[test]
25710    fn minified_detection_true_when_enabled() {
25711        let mut form = blank_form();
25712        form.minified_file_detection = Some("enabled".to_string());
25713        assert!(apply(&form).analysis.minified_file_detection);
25714    }
25715
25716    #[test]
25717    fn minified_detection_true_when_absent() {
25718        assert!(apply(&blank_form()).analysis.minified_file_detection);
25719    }
25720
25721    // ── vendor_directory_detection ──
25722
25723    #[test]
25724    fn vendor_detection_false_when_disabled() {
25725        let mut form = blank_form();
25726        form.vendor_directory_detection = Some("disabled".to_string());
25727        assert!(!apply(&form).analysis.vendor_directory_detection);
25728    }
25729
25730    #[test]
25731    fn vendor_detection_true_when_enabled() {
25732        let mut form = blank_form();
25733        form.vendor_directory_detection = Some("enabled".to_string());
25734        assert!(apply(&form).analysis.vendor_directory_detection);
25735    }
25736
25737    #[test]
25738    fn vendor_detection_true_when_absent() {
25739        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
25740    }
25741
25742    // ── include_lockfiles (select: "disabled" default | "enabled") ──
25743
25744    #[test]
25745    fn lockfiles_false_when_absent() {
25746        // None == Some("enabled") is false → lockfiles off (correct safe default)
25747        assert!(!apply(&blank_form()).analysis.include_lockfiles);
25748    }
25749
25750    #[test]
25751    fn lockfiles_false_when_disabled() {
25752        let mut form = blank_form();
25753        form.include_lockfiles = Some("disabled".to_string());
25754        assert!(!apply(&form).analysis.include_lockfiles);
25755    }
25756
25757    #[test]
25758    fn lockfiles_true_when_enabled() {
25759        let mut form = blank_form();
25760        form.include_lockfiles = Some("enabled".to_string());
25761        assert!(apply(&form).analysis.include_lockfiles);
25762    }
25763
25764    // ── count_compiler_directives ──
25765
25766    #[test]
25767    fn compiler_directives_true_when_absent() {
25768        assert!(
25769            apply(&blank_form()).analysis.count_compiler_directives,
25770            "absent count_compiler_directives must default to true"
25771        );
25772    }
25773
25774    #[test]
25775    fn compiler_directives_true_when_enabled() {
25776        let mut form = blank_form();
25777        form.count_compiler_directives = Some("enabled".to_string());
25778        assert!(apply(&form).analysis.count_compiler_directives);
25779    }
25780
25781    #[test]
25782    fn compiler_directives_false_when_disabled() {
25783        let mut form = blank_form();
25784        form.count_compiler_directives = Some("disabled".to_string());
25785        assert!(!apply(&form).analysis.count_compiler_directives);
25786    }
25787
25788    // ── mixed_line_policy (enum select) ──
25789
25790    #[test]
25791    fn mixed_policy_unchanged_when_absent() {
25792        // None → if-let does nothing → stays at config default (CodeOnly)
25793        assert_eq!(
25794            apply(&blank_form()).analysis.mixed_line_policy,
25795            MixedLinePolicy::CodeOnly
25796        );
25797    }
25798
25799    #[test]
25800    fn mixed_policy_code_only() {
25801        let mut form = blank_form();
25802        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
25803        assert_eq!(
25804            apply(&form).analysis.mixed_line_policy,
25805            MixedLinePolicy::CodeOnly
25806        );
25807    }
25808
25809    #[test]
25810    fn mixed_policy_code_and_comment() {
25811        let mut form = blank_form();
25812        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
25813        assert_eq!(
25814            apply(&form).analysis.mixed_line_policy,
25815            MixedLinePolicy::CodeAndComment
25816        );
25817    }
25818
25819    #[test]
25820    fn mixed_policy_comment_only() {
25821        let mut form = blank_form();
25822        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
25823        assert_eq!(
25824            apply(&form).analysis.mixed_line_policy,
25825            MixedLinePolicy::CommentOnly
25826        );
25827    }
25828
25829    #[test]
25830    fn mixed_policy_separate_mixed_category() {
25831        let mut form = blank_form();
25832        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
25833        assert_eq!(
25834            apply(&form).analysis.mixed_line_policy,
25835            MixedLinePolicy::SeparateMixedCategory
25836        );
25837    }
25838
25839    // ── binary_file_behavior (enum select) ──
25840
25841    #[test]
25842    fn binary_behavior_skip_when_absent() {
25843        assert_eq!(
25844            apply(&blank_form()).analysis.binary_file_behavior,
25845            BinaryFileBehavior::Skip
25846        );
25847    }
25848
25849    #[test]
25850    fn binary_behavior_skip() {
25851        let mut form = blank_form();
25852        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
25853        assert_eq!(
25854            apply(&form).analysis.binary_file_behavior,
25855            BinaryFileBehavior::Skip
25856        );
25857    }
25858
25859    #[test]
25860    fn binary_behavior_fail() {
25861        let mut form = blank_form();
25862        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
25863        assert_eq!(
25864            apply(&form).analysis.binary_file_behavior,
25865            BinaryFileBehavior::Fail
25866        );
25867    }
25868
25869    // ── continuation_line_policy (enum select) ──
25870
25871    #[test]
25872    fn continuation_policy_each_physical_when_absent() {
25873        assert_eq!(
25874            apply(&blank_form()).analysis.continuation_line_policy,
25875            ContinuationLinePolicy::EachPhysicalLine
25876        );
25877    }
25878
25879    #[test]
25880    fn continuation_policy_collapse_to_logical() {
25881        let mut form = blank_form();
25882        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
25883        assert_eq!(
25884            apply(&form).analysis.continuation_line_policy,
25885            ContinuationLinePolicy::CollapseToLogical
25886        );
25887    }
25888
25889    // ── blank_in_block_comment_policy (enum select) ──
25890
25891    #[test]
25892    fn blank_in_block_comment_count_as_comment_when_absent() {
25893        assert_eq!(
25894            apply(&blank_form()).analysis.blank_in_block_comment_policy,
25895            BlankInBlockCommentPolicy::CountAsComment
25896        );
25897    }
25898
25899    #[test]
25900    fn blank_in_block_comment_count_as_blank() {
25901        let mut form = blank_form();
25902        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
25903        assert_eq!(
25904            apply(&form).analysis.blank_in_block_comment_policy,
25905            BlankInBlockCommentPolicy::CountAsBlank
25906        );
25907    }
25908
25909    // ── style_col_threshold ──
25910
25911    #[test]
25912    fn style_threshold_80() {
25913        let mut form = blank_form();
25914        form.style_col_threshold = Some("80".to_string());
25915        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
25916    }
25917
25918    #[test]
25919    fn style_threshold_100() {
25920        let mut form = blank_form();
25921        form.style_col_threshold = Some("100".to_string());
25922        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
25923    }
25924
25925    #[test]
25926    fn style_threshold_120() {
25927        let mut form = blank_form();
25928        form.style_col_threshold = Some("120".to_string());
25929        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
25930    }
25931
25932    #[test]
25933    fn style_threshold_invalid_value_leaves_default() {
25934        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
25935        let mut cfg = sloc_config::AppConfig::default();
25936        let mut form = blank_form();
25937        form.style_col_threshold = Some("42".to_string());
25938        apply_form_to_config(&mut cfg, &form);
25939        assert_eq!(
25940            cfg.analysis.style_col_threshold, 80,
25941            "invalid threshold must not change config"
25942        );
25943    }
25944
25945    #[test]
25946    fn style_threshold_non_numeric_leaves_default() {
25947        let mut cfg = sloc_config::AppConfig::default();
25948        let mut form = blank_form();
25949        form.style_col_threshold = Some("large".to_string());
25950        apply_form_to_config(&mut cfg, &form);
25951        assert_eq!(cfg.analysis.style_col_threshold, 80);
25952    }
25953
25954    #[test]
25955    fn style_threshold_zero_leaves_default() {
25956        let mut cfg = sloc_config::AppConfig::default();
25957        let mut form = blank_form();
25958        form.style_col_threshold = Some("0".to_string());
25959        apply_form_to_config(&mut cfg, &form);
25960        assert_eq!(cfg.analysis.style_col_threshold, 80);
25961    }
25962
25963    #[test]
25964    fn style_threshold_absent_leaves_default() {
25965        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
25966    }
25967
25968    // ── coverage_file ──
25969
25970    #[test]
25971    fn coverage_file_none_when_absent() {
25972        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
25973    }
25974
25975    #[test]
25976    fn coverage_file_none_when_whitespace_only() {
25977        let mut form = blank_form();
25978        form.coverage_file = Some("   ".to_string());
25979        assert!(
25980            apply(&form).analysis.coverage_file.is_none(),
25981            "whitespace-only coverage_file must be treated as None"
25982        );
25983    }
25984
25985    #[test]
25986    fn coverage_file_set_when_non_empty() {
25987        let mut form = blank_form();
25988        form.coverage_file = Some("coverage/lcov.info".to_string());
25989        assert_eq!(
25990            apply(&form).analysis.coverage_file,
25991            Some(std::path::PathBuf::from("coverage/lcov.info"))
25992        );
25993    }
25994
25995    #[test]
25996    fn coverage_file_trims_whitespace() {
25997        let mut form = blank_form();
25998        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
25999        assert_eq!(
26000            apply(&form).analysis.coverage_file,
26001            Some(std::path::PathBuf::from("coverage/lcov.info"))
26002        );
26003    }
26004
26005    // ── report_title ──
26006
26007    #[test]
26008    fn report_title_unchanged_when_absent() {
26009        let original = sloc_config::AppConfig::default().reporting.report_title;
26010        assert_eq!(apply(&blank_form()).reporting.report_title, original);
26011    }
26012
26013    #[test]
26014    fn report_title_unchanged_when_whitespace_only() {
26015        let original = sloc_config::AppConfig::default().reporting.report_title;
26016        let mut form = blank_form();
26017        form.report_title = Some("   ".to_string());
26018        assert_eq!(
26019            apply(&form).reporting.report_title,
26020            original,
26021            "whitespace-only title must not overwrite the default"
26022        );
26023    }
26024
26025    #[test]
26026    fn report_title_updated_and_trimmed() {
26027        let mut form = blank_form();
26028        form.report_title = Some("  My Project  ".to_string());
26029        assert_eq!(apply(&form).reporting.report_title, "My Project");
26030    }
26031
26032    // ── report_header_footer ──
26033
26034    #[test]
26035    fn header_footer_none_when_absent() {
26036        assert!(apply(&blank_form())
26037            .reporting
26038            .report_header_footer
26039            .is_none());
26040    }
26041
26042    #[test]
26043    fn header_footer_none_when_whitespace_only() {
26044        let mut form = blank_form();
26045        form.report_header_footer = Some("  ".to_string());
26046        assert!(apply(&form).reporting.report_header_footer.is_none());
26047    }
26048
26049    #[test]
26050    fn header_footer_set_and_trimmed() {
26051        let mut form = blank_form();
26052        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
26053        assert_eq!(
26054            apply(&form).reporting.report_header_footer,
26055            Some("Confidential — Internal Use".to_string())
26056        );
26057    }
26058
26059    // ── include_globs / exclude_globs ──
26060
26061    #[test]
26062    fn include_globs_empty_when_absent() {
26063        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
26064    }
26065
26066    #[test]
26067    fn include_globs_newline_separated() {
26068        let mut form = blank_form();
26069        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
26070        assert_eq!(
26071            apply(&form).discovery.include_globs,
26072            vec!["src/**/*.rs", "tests/**/*.rs"]
26073        );
26074    }
26075
26076    #[test]
26077    fn exclude_globs_comma_separated() {
26078        let mut form = blank_form();
26079        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
26080        assert_eq!(
26081            apply(&form).discovery.exclude_globs,
26082            vec!["vendor/**", "node_modules/**"]
26083        );
26084    }
26085
26086    #[test]
26087    fn globs_mixed_separators() {
26088        let mut form = blank_form();
26089        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
26090        assert_eq!(
26091            apply(&form).discovery.exclude_globs,
26092            vec!["a/**", "b/**", "c/**"]
26093        );
26094    }
26095
26096    // ── split_patterns unit tests ──
26097
26098    #[test]
26099    fn split_patterns_none_is_empty() {
26100        assert!(split_patterns(None).is_empty());
26101    }
26102
26103    #[test]
26104    fn split_patterns_empty_string_is_empty() {
26105        assert!(split_patterns(Some("")).is_empty());
26106    }
26107
26108    #[test]
26109    fn split_patterns_whitespace_only_is_empty() {
26110        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
26111    }
26112
26113    #[test]
26114    fn split_patterns_newlines() {
26115        assert_eq!(
26116            split_patterns(Some("a/**\nb/**\nc/**")),
26117            vec!["a/**", "b/**", "c/**"]
26118        );
26119    }
26120
26121    #[test]
26122    fn split_patterns_commas() {
26123        assert_eq!(
26124            split_patterns(Some("a/**,b/**,c/**")),
26125            vec!["a/**", "b/**", "c/**"]
26126        );
26127    }
26128
26129    #[test]
26130    fn split_patterns_mixed() {
26131        assert_eq!(
26132            split_patterns(Some("a/**\nb/**,c/**")),
26133            vec!["a/**", "b/**", "c/**"]
26134        );
26135    }
26136
26137    #[test]
26138    fn split_patterns_trims_whitespace() {
26139        assert_eq!(
26140            split_patterns(Some("  a/**  \n  b/**  ")),
26141            vec!["a/**", "b/**"]
26142        );
26143    }
26144
26145    #[test]
26146    fn split_patterns_filters_empty_entries() {
26147        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
26148    }
26149
26150    #[test]
26151    fn split_patterns_single_entry() {
26152        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
26153    }
26154}
26155
26156#[cfg(test)]
26157mod utility_tests {
26158    use super::*;
26159    use std::net::IpAddr;
26160    use std::time::Duration;
26161
26162    // ── sanitize_project_label ────────────────────────────────────────────────
26163
26164    #[test]
26165    fn sanitize_simple_name() {
26166        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
26167    }
26168
26169    #[test]
26170    fn sanitize_uppercased_lowercased() {
26171        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
26172    }
26173
26174    #[test]
26175    fn sanitize_path_extracts_filename() {
26176        assert_eq!(
26177            sanitize_project_label("/home/user/my-project"),
26178            "my-project"
26179        );
26180    }
26181
26182    #[test]
26183    fn sanitize_path_uses_last_component() {
26184        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
26185    }
26186
26187    #[test]
26188    fn sanitize_spaces_become_hyphens() {
26189        assert_eq!(sanitize_project_label("my project"), "my-project");
26190    }
26191
26192    #[test]
26193    fn sanitize_non_ascii_become_hyphens() {
26194        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
26195    }
26196
26197    #[test]
26198    fn sanitize_all_special_chars_gives_project() {
26199        assert_eq!(sanitize_project_label("!@#$%^"), "project");
26200    }
26201
26202    #[test]
26203    fn sanitize_empty_string_gives_project() {
26204        assert_eq!(sanitize_project_label(""), "project");
26205    }
26206
26207    #[test]
26208    fn sanitize_leading_trailing_hyphens_stripped() {
26209        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
26210    }
26211
26212    #[test]
26213    fn sanitize_alphanumeric_preserved() {
26214        assert_eq!(sanitize_project_label("repo123"), "repo123");
26215    }
26216
26217    #[test]
26218    fn sanitize_dots_become_hyphens() {
26219        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
26220    }
26221
26222    #[test]
26223    fn sanitize_mixed_slashes_uses_filename() {
26224        // The Windows path separator — on all platforms Path::file_name still works
26225        assert_eq!(sanitize_project_label("project-name"), "project-name");
26226    }
26227
26228    // ── IpRateLimiter ─────────────────────────────────────────────────────────
26229
26230    #[test]
26231    fn rate_limiter_allows_first_request() {
26232        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
26233        let ip: IpAddr = "127.0.0.1".parse().unwrap();
26234        assert!(rl.is_allowed(ip));
26235    }
26236
26237    #[test]
26238    fn rate_limiter_blocks_after_limit_reached() {
26239        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
26240        let ip: IpAddr = "10.0.0.1".parse().unwrap();
26241        assert!(rl.is_allowed(ip));
26242        assert!(rl.is_allowed(ip));
26243        assert!(rl.is_allowed(ip));
26244        assert!(!rl.is_allowed(ip), "4th request must be blocked");
26245    }
26246
26247    #[test]
26248    fn rate_limiter_allows_requests_up_to_limit() {
26249        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
26250        let ip: IpAddr = "10.0.0.2".parse().unwrap();
26251        for _ in 0..5 {
26252            assert!(rl.is_allowed(ip));
26253        }
26254        assert!(!rl.is_allowed(ip), "6th request must be blocked");
26255    }
26256
26257    #[test]
26258    fn rate_limiter_different_ips_are_independent() {
26259        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
26260        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
26261        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
26262        assert!(rl.is_allowed(ip1));
26263        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
26264        assert!(rl.is_allowed(ip2), "ip2 must be independent");
26265    }
26266
26267    #[test]
26268    fn rate_limiter_auth_failure_not_locked_below_threshold() {
26269        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
26270        let ip: IpAddr = "10.0.0.3".parse().unwrap();
26271        rl.record_auth_failure(ip);
26272        rl.record_auth_failure(ip);
26273        assert!(
26274            !rl.is_auth_locked_out(ip),
26275            "not locked at 2 failures when threshold is 3"
26276        );
26277    }
26278
26279    #[test]
26280    fn rate_limiter_auth_failure_locked_at_threshold() {
26281        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
26282        let ip: IpAddr = "10.0.0.4".parse().unwrap();
26283        rl.record_auth_failure(ip);
26284        rl.record_auth_failure(ip);
26285        rl.record_auth_failure(ip);
26286        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
26287    }
26288
26289    #[test]
26290    fn rate_limiter_auth_failure_different_ips_independent() {
26291        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
26292        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
26293        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
26294        rl.record_auth_failure(ip1);
26295        rl.record_auth_failure(ip1);
26296        assert!(rl.is_auth_locked_out(ip1));
26297        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
26298    }
26299
26300    #[test]
26301    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
26302        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
26303        let ip: IpAddr = "127.0.0.2".parse().unwrap();
26304        for _ in 0..100 {
26305            assert!(rl.is_allowed(ip));
26306        }
26307    }
26308
26309    // ── strip_unc_prefix ──────────────────────────────────────────────────────
26310
26311    #[test]
26312    fn strip_unc_plain_path_unchanged() {
26313        let p = PathBuf::from("C:\\Users\\user\\project");
26314        let result = strip_unc_prefix(p.clone());
26315        assert_eq!(result, p);
26316    }
26317
26318    #[test]
26319    fn strip_unc_with_drive_prefix_stripped() {
26320        let p = PathBuf::from(r"\\?\C:\Users\user\project");
26321        let result = strip_unc_prefix(p);
26322        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
26323    }
26324
26325    #[test]
26326    fn strip_unc_with_network_prefix_stripped() {
26327        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
26328        let result = strip_unc_prefix(p);
26329        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
26330    }
26331
26332    #[test]
26333    fn strip_unc_linux_path_unchanged() {
26334        let p = PathBuf::from("/home/user/project");
26335        let result = strip_unc_prefix(p.clone());
26336        assert_eq!(result, p);
26337    }
26338
26339    // ── remote_to_commit_url ──────────────────────────────────────────────────
26340
26341    #[test]
26342    fn remote_to_commit_url_github_https() {
26343        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
26344        assert_eq!(
26345            url,
26346            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
26347        );
26348    }
26349
26350    #[test]
26351    fn remote_to_commit_url_github_ssh() {
26352        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
26353        assert_eq!(
26354            url,
26355            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
26356        );
26357    }
26358
26359    #[test]
26360    fn remote_to_commit_url_gitlab_uses_dash_commit() {
26361        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
26362        assert_eq!(
26363            url,
26364            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
26365        );
26366    }
26367
26368    #[test]
26369    fn remote_to_commit_url_bitbucket_uses_commits() {
26370        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
26371        assert_eq!(
26372            url,
26373            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
26374        );
26375    }
26376
26377    #[test]
26378    fn remote_to_commit_url_unknown_scheme_returns_none() {
26379        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
26380        assert!(url.is_none());
26381    }
26382
26383    #[test]
26384    fn remote_to_commit_url_ssh_gitlab() {
26385        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
26386        assert!(url.is_some());
26387        let u = url.unwrap();
26388        assert!(
26389            u.contains("/-/commit/sha123"),
26390            "gitlab ssh must use /-/commit/"
26391        );
26392    }
26393
26394    // ── git_clone_dest ────────────────────────────────────────────────────────
26395
26396    #[test]
26397    fn git_clone_dest_github_url_produces_safe_name() {
26398        let dir = PathBuf::from("/tmp/clones");
26399        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
26400        let name = dest.file_name().unwrap().to_string_lossy();
26401        assert!(!name.is_empty());
26402        assert!(
26403            name.chars()
26404                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
26405            "clone dest must only contain safe chars, got: {name}"
26406        );
26407    }
26408
26409    #[test]
26410    fn git_clone_dest_is_inside_clones_dir() {
26411        let dir = PathBuf::from("/tmp/clones");
26412        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
26413        assert!(
26414            dest.starts_with(&dir),
26415            "clone dest must be inside clones_dir"
26416        );
26417    }
26418
26419    #[test]
26420    fn git_clone_dest_truncates_to_80_chars_max() {
26421        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
26422        let dir = PathBuf::from("/tmp/clones");
26423        let dest = git_clone_dest(&long_url, &dir);
26424        let name = dest.file_name().unwrap().to_string_lossy();
26425        assert!(
26426            name.len() <= 80,
26427            "clone dest name must be at most 80 chars, got {} chars: {name}",
26428            name.len()
26429        );
26430    }
26431
26432    #[test]
26433    fn git_clone_dest_special_chars_replaced_with_underscore() {
26434        let dir = PathBuf::from("/tmp/clones");
26435        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
26436        let name = dest.file_name().unwrap().to_string_lossy();
26437        assert!(
26438            !name.contains('@') && !name.contains(':') && !name.contains('/'),
26439            "special chars must be replaced in clone dest, got: {name}"
26440        );
26441    }
26442
26443    #[test]
26444    fn git_clone_dest_different_urls_differ() {
26445        let dir = PathBuf::from("/tmp/clones");
26446        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
26447        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
26448        assert_ne!(
26449            a, b,
26450            "different repos must produce different clone dest names"
26451        );
26452    }
26453
26454    #[test]
26455    fn git_clone_dest_same_url_same_result() {
26456        let dir = PathBuf::from("/tmp/clones");
26457        let url = "https://github.com/owner/repo.git";
26458        assert_eq!(
26459            git_clone_dest(url, &dir),
26460            git_clone_dest(url, &dir),
26461            "same URL must always give same clone dest"
26462        );
26463    }
26464
26465    // ── fmt_delta ─────────────────────────────────────────────────────────────
26466
26467    #[test]
26468    fn fmt_delta_positive_has_plus_prefix() {
26469        assert_eq!(fmt_delta(5), "+5");
26470    }
26471
26472    #[test]
26473    fn fmt_delta_negative_no_plus_prefix() {
26474        assert_eq!(fmt_delta(-3), "-3");
26475    }
26476
26477    #[test]
26478    fn fmt_delta_zero() {
26479        assert_eq!(fmt_delta(0), "0");
26480    }
26481
26482    // ── delta_class ───────────────────────────────────────────────────────────
26483
26484    #[test]
26485    fn delta_class_positive_is_pos() {
26486        assert_eq!(delta_class(1), "pos");
26487    }
26488
26489    #[test]
26490    fn delta_class_negative_is_neg() {
26491        assert_eq!(delta_class(-1), "neg");
26492    }
26493
26494    #[test]
26495    fn delta_class_zero_is_zero_class() {
26496        assert_eq!(delta_class(0), "zero");
26497    }
26498
26499    // ── fmt_pct ───────────────────────────────────────────────────────────────
26500
26501    #[test]
26502    fn fmt_pct_zero_baseline_returns_em_dash() {
26503        assert_eq!(fmt_pct(100, 0), "\u{2014}");
26504    }
26505
26506    #[test]
26507    fn fmt_pct_positive_delta_has_plus_sign() {
26508        let result = fmt_pct(10, 100);
26509        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
26510    }
26511
26512    #[test]
26513    fn fmt_pct_negative_delta_no_plus_sign() {
26514        let result = fmt_pct(-10, 100);
26515        assert!(!result.starts_with('+'), "unexpected + in: {result}");
26516        assert!(result.contains('%'));
26517    }
26518
26519    #[test]
26520    fn fmt_pct_near_zero_returns_pm_zero() {
26521        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
26522    }
26523
26524    // ── summary_delta ─────────────────────────────────────────────────────────
26525
26526    #[test]
26527    fn summary_delta_no_prev_returns_dash_na() {
26528        let (display, class) = summary_delta(10, None);
26529        assert_eq!(display, "\u{2014}");
26530        assert_eq!(class, "na");
26531    }
26532
26533    #[test]
26534    fn summary_delta_increase_is_positive() {
26535        let (display, class) = summary_delta(15, Some(10));
26536        assert_eq!(display, "+5");
26537        assert_eq!(class, "pos");
26538    }
26539
26540    #[test]
26541    fn summary_delta_decrease_is_negative() {
26542        let (display, class) = summary_delta(5, Some(10));
26543        assert_eq!(display, "-5");
26544        assert_eq!(class, "neg");
26545    }
26546
26547    // ── nth_weekday_of_month ──────────────────────────────────────────────────
26548
26549    #[test]
26550    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
26551        use chrono::Datelike;
26552        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
26553        assert_eq!(d.year(), 2024);
26554        assert_eq!(d.month(), 1);
26555        assert_eq!(d.weekday(), chrono::Weekday::Mon);
26556        assert!(d.day() <= 7);
26557    }
26558
26559    #[test]
26560    fn nth_weekday_second_sunday_march_2024_is_10th() {
26561        use chrono::Datelike;
26562        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
26563        assert_eq!(d.weekday(), chrono::Weekday::Sun);
26564        assert_eq!(d.month(), 3);
26565        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
26566    }
26567
26568    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
26569
26570    #[test]
26571    fn is_pacific_dst_july_is_true() {
26572        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
26573        assert!(is_pacific_dst(dt), "July must be PDT");
26574    }
26575
26576    #[test]
26577    fn is_pacific_dst_january_is_false() {
26578        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
26579        assert!(!is_pacific_dst(dt), "January must be PST");
26580    }
26581
26582    #[test]
26583    fn fmt_la_time_summer_shows_pdt() {
26584        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
26585        let result = fmt_la_time(dt);
26586        assert!(
26587            result.ends_with("PDT"),
26588            "summer must use PDT, got: {result}"
26589        );
26590    }
26591
26592    #[test]
26593    fn fmt_la_time_winter_shows_pst() {
26594        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
26595        let result = fmt_la_time(dt);
26596        assert!(
26597            result.ends_with("PST"),
26598            "winter must use PST, got: {result}"
26599        );
26600    }
26601
26602    #[test]
26603    fn fmt_la_time_meta_summer_shows_pdt() {
26604        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
26605        let result = fmt_la_time_meta(dt);
26606        assert!(
26607            result.ends_with("PDT"),
26608            "meta summer must use PDT, got: {result}"
26609        );
26610    }
26611
26612    #[test]
26613    fn fmt_la_time_meta_winter_shows_pst() {
26614        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
26615        let result = fmt_la_time_meta(dt);
26616        assert!(
26617            result.ends_with("PST"),
26618            "meta winter must use PST, got: {result}"
26619        );
26620    }
26621
26622    // ── fmt_git_date ──────────────────────────────────────────────────────────
26623
26624    #[test]
26625    fn fmt_git_date_valid_iso_returns_some() {
26626        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
26627    }
26628
26629    #[test]
26630    fn fmt_git_date_invalid_returns_none() {
26631        assert!(fmt_git_date("not-a-date").is_none());
26632    }
26633
26634    // ── format_number ─────────────────────────────────────────────────────────
26635
26636    #[test]
26637    fn format_number_zero() {
26638        assert_eq!(format_number(0), "0");
26639    }
26640
26641    #[test]
26642    fn format_number_three_digits_no_comma() {
26643        assert_eq!(format_number(999), "999");
26644    }
26645
26646    #[test]
26647    fn format_number_four_digits_has_comma() {
26648        assert_eq!(format_number(1000), "1,000");
26649    }
26650
26651    #[test]
26652    fn format_number_seven_digits_two_commas() {
26653        assert_eq!(format_number(1_234_567), "1,234,567");
26654    }
26655
26656    #[test]
26657    fn format_number_one_million() {
26658        assert_eq!(format_number(1_000_000), "1,000,000");
26659    }
26660
26661    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
26662
26663    #[test]
26664    fn badge_text_px_empty_is_zero() {
26665        assert_eq!(badge_text_px(""), 0);
26666    }
26667
26668    #[test]
26669    fn badge_text_px_narrow_chars_smaller_than_normal() {
26670        assert!(
26671            badge_text_px("if") < badge_text_px("ab"),
26672            "'if' must be narrower than 'ab'"
26673        );
26674    }
26675
26676    #[test]
26677    fn badge_text_px_m_is_wider_than_a() {
26678        assert!(
26679            badge_text_px("m") > badge_text_px("a"),
26680            "'m' must be wider than 'a'"
26681        );
26682    }
26683
26684    #[test]
26685    fn render_badge_svg_contains_label_and_value() {
26686        let svg = render_badge_svg("coverage", "95%", "#4c1");
26687        assert!(svg.contains("coverage") && svg.contains("95%"));
26688    }
26689
26690    #[test]
26691    fn render_badge_svg_contains_color() {
26692        let svg = render_badge_svg("sloc", "12K", "#e05d44");
26693        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
26694    }
26695
26696    #[test]
26697    fn render_badge_svg_escapes_ampersand_in_label() {
26698        let svg = render_badge_svg("test&label", "ok", "#4c1");
26699        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
26700    }
26701
26702    // ── build_pdf_filename ────────────────────────────────────────────────────
26703
26704    #[test]
26705    fn build_pdf_filename_slugifies_title() {
26706        let name = build_pdf_filename("My Project Report", "abc-def-1234");
26707        assert!(
26708            name.starts_with("my_project_report_")
26709                && std::path::Path::new(&name)
26710                    .extension()
26711                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
26712        );
26713    }
26714
26715    #[test]
26716    fn build_pdf_filename_uses_last_run_id_segment() {
26717        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
26718        assert!(name.contains("ABCD"), "must use last segment of run_id");
26719    }
26720
26721    #[test]
26722    fn build_pdf_filename_empty_title_uses_report_prefix() {
26723        let name = build_pdf_filename("", "abc-def-9999");
26724        assert!(
26725            name.starts_with("report_")
26726                && std::path::Path::new(&name)
26727                    .extension()
26728                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
26729        );
26730    }
26731
26732    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
26733
26734    #[test]
26735    fn swap_chart_js_replaces_inline_block() {
26736        let html = "<html><head><script>// inline source</script></head><body></body></html>";
26737        let result = swap_inline_chart_js_for_static(html.to_string());
26738        assert!(result.contains(r#"src="/static/chart-report.js""#));
26739        assert!(!result.contains("inline source"));
26740    }
26741
26742    #[test]
26743    fn swap_chart_js_no_head_returns_unchanged() {
26744        let html = "<body>no head here</body>";
26745        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
26746    }
26747
26748    #[test]
26749    fn swap_chart_js_no_script_in_head_unchanged() {
26750        let html = "<html><head><style>.x{}</style></head><body></body></html>";
26751        let result = swap_inline_chart_js_for_static(html.to_string());
26752        assert!(!result.contains("chart-report.js"));
26753    }
26754
26755    // ── patch_html_nonce ──────────────────────────────────────────────────────
26756
26757    #[test]
26758    fn patch_html_nonce_replaces_old_nonce() {
26759        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
26760        let result = patch_html_nonce(html, "new-nonce-456");
26761        assert!(result.contains(r#"nonce="new-nonce-456""#));
26762        assert!(!result.contains("old-nonce-123"));
26763    }
26764
26765    #[test]
26766    fn patch_html_nonce_injects_into_bare_style() {
26767        let html = "<style>body{color:red;}</style>";
26768        let result = patch_html_nonce(html, "fresh-nonce");
26769        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
26770    }
26771
26772    #[test]
26773    fn patch_html_nonce_injects_into_bare_script() {
26774        let html = "<script>console.log(1);</script>";
26775        let result = patch_html_nonce(html, "abc");
26776        assert!(result.contains(r#"<script nonce="abc">"#));
26777    }
26778
26779    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
26780
26781    #[test]
26782    fn is_html_report_file_result_html_matches() {
26783        let dir = tempfile::tempdir().unwrap();
26784        let path = dir.path().join("result_20240101.html");
26785        std::fs::write(&path, b"<html></html>").unwrap();
26786        assert!(is_html_report_file(&path));
26787    }
26788
26789    #[test]
26790    fn is_html_report_file_report_html_matches() {
26791        let dir = tempfile::tempdir().unwrap();
26792        let path = dir.path().join("report_abc.html");
26793        std::fs::write(&path, b"<html></html>").unwrap();
26794        assert!(is_html_report_file(&path));
26795    }
26796
26797    #[test]
26798    fn is_html_report_file_index_html_does_not_match() {
26799        let dir = tempfile::tempdir().unwrap();
26800        let path = dir.path().join("index.html");
26801        std::fs::write(&path, b"<html></html>").unwrap();
26802        assert!(!is_html_report_file(&path));
26803    }
26804
26805    #[test]
26806    fn is_html_report_file_nonexistent_returns_false() {
26807        assert!(!is_html_report_file(Path::new(
26808            "/nonexistent/result_xyz.html"
26809        )));
26810    }
26811
26812    #[test]
26813    fn find_html_report_in_dir_finds_result_html() {
26814        let dir = tempfile::tempdir().unwrap();
26815        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
26816        assert!(find_html_report_in_dir(dir.path()).is_some());
26817    }
26818
26819    #[test]
26820    fn find_html_report_in_dir_empty_returns_none() {
26821        let dir = tempfile::tempdir().unwrap();
26822        assert!(find_html_report_in_dir(dir.path()).is_none());
26823    }
26824
26825    #[test]
26826    fn find_html_report_in_tree_finds_in_subdir() {
26827        let dir = tempfile::tempdir().unwrap();
26828        let subdir = dir.path().join("run-001");
26829        std::fs::create_dir_all(&subdir).unwrap();
26830        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
26831        assert!(find_html_report_in_tree(dir.path()).is_some());
26832    }
26833
26834    // ── derive_project_label ──────────────────────────────────────────────────
26835
26836    #[test]
26837    fn derive_project_label_with_git_repo_and_ref() {
26838        let label = derive_project_label(
26839            Some("https://github.com/owner/my-repo.git"),
26840            Some("main"),
26841            "/fallback/path",
26842        );
26843        assert!(!label.is_empty(), "label must not be empty");
26844        assert!(
26845            label.contains("my") || label.contains("repo"),
26846            "got: {label}"
26847        );
26848    }
26849
26850    #[test]
26851    fn derive_project_label_fallback_to_path() {
26852        let label = derive_project_label(None, None, "/path/to/myproject");
26853        assert_eq!(label, "myproject");
26854    }
26855
26856    #[test]
26857    fn derive_project_label_empty_git_fields_use_path() {
26858        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
26859        assert_eq!(label, "cool-app");
26860    }
26861
26862    // ── derive_file_stem ──────────────────────────────────────────────────────
26863
26864    #[test]
26865    fn derive_file_stem_with_commit_appends_sha() {
26866        assert_eq!(
26867            derive_file_stem("myproject", Some("a1b2c3")),
26868            "myproject_a1b2c3"
26869        );
26870    }
26871
26872    #[test]
26873    fn derive_file_stem_without_commit_returns_label() {
26874        assert_eq!(derive_file_stem("myproject", None), "myproject");
26875    }
26876
26877    #[test]
26878    fn derive_file_stem_empty_commit_returns_label() {
26879        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
26880    }
26881
26882    // ── split_patterns ────────────────────────────────────────────────────────
26883
26884    #[test]
26885    fn split_patterns_none_is_empty() {
26886        assert!(split_patterns(None).is_empty());
26887    }
26888
26889    #[test]
26890    fn split_patterns_empty_string_is_empty() {
26891        assert!(split_patterns(Some("")).is_empty());
26892    }
26893
26894    #[test]
26895    fn split_patterns_comma_separated() {
26896        assert_eq!(
26897            split_patterns(Some("foo,bar,baz")),
26898            vec!["foo", "bar", "baz"]
26899        );
26900    }
26901
26902    #[test]
26903    fn split_patterns_newline_separated() {
26904        assert_eq!(
26905            split_patterns(Some("foo\nbar\nbaz")),
26906            vec!["foo", "bar", "baz"]
26907        );
26908    }
26909
26910    #[test]
26911    fn split_patterns_trims_whitespace() {
26912        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
26913    }
26914
26915    // ── make_git_label ────────────────────────────────────────────────────────
26916
26917    #[test]
26918    fn make_git_label_empty_repo_empty_result() {
26919        assert_eq!(make_git_label("", "main"), "");
26920    }
26921
26922    #[test]
26923    fn make_git_label_empty_ref_empty_result() {
26924        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
26925    }
26926
26927    #[test]
26928    fn make_git_label_basic_format() {
26929        assert_eq!(
26930            make_git_label("https://github.com/owner/my-repo.git", "main"),
26931            "my-repo_at_main_sloc"
26932        );
26933    }
26934
26935    #[test]
26936    fn make_git_label_slash_in_ref_replaced() {
26937        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
26938        assert!(
26939            !label.contains('/'),
26940            "slash in ref must be replaced: {label}"
26941        );
26942    }
26943
26944    // ── format_dir_size ───────────────────────────────────────────────────────
26945
26946    #[test]
26947    fn format_dir_size_bytes() {
26948        assert_eq!(format_dir_size(500), "500 B");
26949    }
26950
26951    #[test]
26952    fn format_dir_size_kilobytes() {
26953        assert_eq!(format_dir_size(2048), "2 KB");
26954    }
26955
26956    #[test]
26957    fn format_dir_size_megabytes() {
26958        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
26959    }
26960
26961    #[test]
26962    fn format_dir_size_gigabytes() {
26963        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
26964    }
26965
26966    #[test]
26967    fn format_dir_size_zero() {
26968        assert_eq!(format_dir_size(0), "0 B");
26969    }
26970
26971    // ── civil_from_days ───────────────────────────────────────────────────────
26972
26973    #[test]
26974    fn civil_from_days_epoch() {
26975        assert_eq!(civil_from_days(0), (1970, 1, 1));
26976    }
26977
26978    #[test]
26979    fn civil_from_days_one_year_later() {
26980        assert_eq!(civil_from_days(365), (1971, 1, 1));
26981    }
26982
26983    #[test]
26984    fn civil_from_days_31_days_is_feb_1_1970() {
26985        assert_eq!(civil_from_days(31), (1970, 2, 1));
26986    }
26987
26988    // ── format_system_time ────────────────────────────────────────────────────
26989
26990    #[test]
26991    fn format_system_time_unix_epoch_formats_correctly() {
26992        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
26993    }
26994
26995    #[test]
26996    fn format_system_time_31_days_after_epoch() {
26997        let t = UNIX_EPOCH + Duration::from_hours(744);
26998        assert_eq!(format_system_time(t), "1970-02-01 00:00");
26999    }
27000
27001    #[test]
27002    fn format_system_time_before_epoch_returns_dash() {
27003        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
27004            assert_eq!(format_system_time(before), "-");
27005        }
27006    }
27007
27008    // ── detect_language_name ──────────────────────────────────────────────────
27009
27010    #[test]
27011    fn detect_language_name_dot_c() {
27012        assert_eq!(detect_language_name("main.c"), Some("C"));
27013    }
27014
27015    #[test]
27016    fn detect_language_name_dot_h() {
27017        assert_eq!(detect_language_name("defs.h"), Some("C"));
27018    }
27019
27020    #[test]
27021    fn detect_language_name_dot_cpp() {
27022        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
27023    }
27024
27025    #[test]
27026    fn detect_language_name_dot_py() {
27027        assert_eq!(detect_language_name("script.py"), Some("Python"));
27028    }
27029
27030    #[test]
27031    fn detect_language_name_dot_ps1() {
27032        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
27033    }
27034
27035    #[test]
27036    fn detect_language_name_dot_cs() {
27037        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
27038    }
27039
27040    #[test]
27041    fn detect_language_name_dot_sh() {
27042        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
27043    }
27044
27045    #[test]
27046    fn detect_language_name_unknown_txt() {
27047        assert_eq!(detect_language_name("notes.txt"), None);
27048    }
27049
27050    // ── language_icon_file ────────────────────────────────────────────────────
27051
27052    #[test]
27053    fn language_icon_file_c() {
27054        assert_eq!(language_icon_file("C"), Some("c.png"));
27055    }
27056
27057    #[test]
27058    fn language_icon_file_python() {
27059        assert_eq!(language_icon_file("Python"), Some("python.png"));
27060    }
27061
27062    #[test]
27063    fn language_icon_file_dockerfile() {
27064        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
27065    }
27066
27067    #[test]
27068    fn language_icon_file_rust_is_none() {
27069        assert!(language_icon_file("Rust").is_none());
27070    }
27071
27072    #[test]
27073    fn language_icon_file_unknown_is_none() {
27074        assert!(language_icon_file("Fortran").is_none());
27075    }
27076
27077    // ── language_inline_svg ───────────────────────────────────────────────────
27078
27079    #[test]
27080    fn language_inline_svg_rust_is_svg() {
27081        let svg = language_inline_svg("Rust").unwrap();
27082        assert!(svg.starts_with("<svg"));
27083    }
27084
27085    #[test]
27086    fn language_inline_svg_typescript_is_some() {
27087        assert!(language_inline_svg("TypeScript").is_some());
27088    }
27089
27090    #[test]
27091    fn language_inline_svg_unknown_is_none() {
27092        assert!(language_inline_svg("Fortran").is_none());
27093    }
27094
27095    // ── classify_preview_file ─────────────────────────────────────────────────
27096
27097    #[test]
27098    fn classify_preview_file_c_supported() {
27099        assert!(matches!(
27100            classify_preview_file("main.c"),
27101            PreviewKind::Supported
27102        ));
27103    }
27104
27105    #[test]
27106    fn classify_preview_file_python_supported() {
27107        assert!(matches!(
27108            classify_preview_file("script.py"),
27109            PreviewKind::Supported
27110        ));
27111    }
27112
27113    #[test]
27114    fn classify_preview_file_png_skipped() {
27115        assert!(matches!(
27116            classify_preview_file("image.png"),
27117            PreviewKind::Skipped
27118        ));
27119    }
27120
27121    #[test]
27122    fn classify_preview_file_zip_skipped() {
27123        assert!(matches!(
27124            classify_preview_file("archive.zip"),
27125            PreviewKind::Skipped
27126        ));
27127    }
27128
27129    #[test]
27130    fn classify_preview_file_min_js_skipped() {
27131        assert!(matches!(
27132            classify_preview_file("bundle.min.js"),
27133            PreviewKind::Skipped
27134        ));
27135    }
27136
27137    #[test]
27138    fn classify_preview_file_rs_unsupported() {
27139        assert!(matches!(
27140            classify_preview_file("main.rs"),
27141            PreviewKind::Unsupported
27142        ));
27143    }
27144
27145    // ── preview_relative_path ─────────────────────────────────────────────────
27146
27147    #[test]
27148    fn preview_relative_path_strips_root() {
27149        let root = PathBuf::from("/project");
27150        let path = PathBuf::from("/project/src/main.c");
27151        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
27152    }
27153
27154    #[test]
27155    fn preview_relative_path_unrooted_includes_filename() {
27156        let root = PathBuf::from("/other");
27157        let path = PathBuf::from("/project/src/main.c");
27158        let result = preview_relative_path(&root, &path);
27159        assert!(result.contains("main.c"));
27160    }
27161
27162    #[test]
27163    fn preview_relative_path_uses_forward_slashes() {
27164        let root = PathBuf::from("/project");
27165        let path = PathBuf::from("/project/a/b/c.py");
27166        assert!(!preview_relative_path(&root, &path).contains('\\'));
27167    }
27168
27169    // ── wildcard_match ────────────────────────────────────────────────────────
27170
27171    #[test]
27172    fn wildcard_match_exact_equal() {
27173        assert!(wildcard_match("foo", "foo"));
27174    }
27175
27176    #[test]
27177    fn wildcard_match_exact_mismatch() {
27178        assert!(!wildcard_match("foo", "bar"));
27179    }
27180
27181    #[test]
27182    fn wildcard_match_star_suffix() {
27183        assert!(wildcard_match("*.rs", "main.rs"));
27184    }
27185
27186    #[test]
27187    fn wildcard_match_star_middle_requires_suffix() {
27188        assert!(!wildcard_match("a*b", "ac"));
27189    }
27190
27191    #[test]
27192    fn wildcard_match_question_mark_single_char() {
27193        assert!(wildcard_match("f?o", "foo"));
27194    }
27195
27196    #[test]
27197    fn wildcard_match_double_star_nested() {
27198        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
27199    }
27200
27201    #[test]
27202    fn wildcard_match_star_directory_entry() {
27203        assert!(wildcard_match("vendor/*", "vendor/crate"));
27204    }
27205
27206    #[test]
27207    fn wildcard_match_no_cross_prefix() {
27208        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
27209    }
27210
27211    // ── should_skip_preview_directory ────────────────────────────────────────
27212
27213    #[test]
27214    fn should_skip_empty_relative_is_false() {
27215        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
27216    }
27217
27218    #[test]
27219    fn should_skip_matching_pattern() {
27220        assert!(should_skip_preview_directory(
27221            "vendor",
27222            &["vendor".to_string()]
27223        ));
27224    }
27225
27226    #[test]
27227    fn should_skip_non_matching() {
27228        assert!(!should_skip_preview_directory(
27229            "src",
27230            &["vendor".to_string()]
27231        ));
27232    }
27233
27234    #[test]
27235    fn should_skip_wildcard_prefix() {
27236        assert!(should_skip_preview_directory(
27237            "target/debug",
27238            &["target*".to_string()]
27239        ));
27240    }
27241
27242    // ── should_include_preview_file ───────────────────────────────────────────
27243
27244    #[test]
27245    fn should_include_empty_relative_always_true() {
27246        assert!(should_include_preview_file("", &[], &[]));
27247    }
27248
27249    #[test]
27250    fn should_include_no_patterns_includes_all() {
27251        assert!(should_include_preview_file("src/main.c", &[], &[]));
27252    }
27253
27254    #[test]
27255    fn should_include_excluded_by_pattern() {
27256        assert!(!should_include_preview_file(
27257            "vendor/lib.c",
27258            &[],
27259            &["vendor/*".to_string()]
27260        ));
27261    }
27262
27263    #[test]
27264    fn should_include_include_pattern_filters() {
27265        assert!(!should_include_preview_file(
27266            "tests/test_foo.c",
27267            &["src/*".to_string()],
27268            &[]
27269        ));
27270    }
27271
27272    // ── escape_html ───────────────────────────────────────────────────────────
27273
27274    #[test]
27275    fn escape_html_ampersand() {
27276        assert_eq!(escape_html("a&b"), "a&amp;b");
27277    }
27278
27279    #[test]
27280    fn escape_html_angle_brackets() {
27281        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
27282    }
27283
27284    #[test]
27285    fn escape_html_double_quote() {
27286        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
27287    }
27288
27289    #[test]
27290    fn escape_html_single_quote() {
27291        assert_eq!(escape_html("it's"), "it&#39;s");
27292    }
27293
27294    #[test]
27295    fn escape_html_plain_text_unchanged() {
27296        assert_eq!(escape_html("hello world"), "hello world");
27297    }
27298
27299    // ── sum_added / removed / unmodified code lines ───────────────────────────
27300
27301    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
27302        sloc_core::ScanComparison {
27303            summary: sloc_core::SummaryDelta {
27304                baseline_run_id: "base".to_string(),
27305                current_run_id: "curr".to_string(),
27306                baseline_timestamp: chrono::Utc::now(),
27307                current_timestamp: chrono::Utc::now(),
27308                baseline_files: 4,
27309                current_files: 4,
27310                files_analyzed_delta: 0,
27311                baseline_code: 330,
27312                current_code: 400,
27313                code_lines_delta: 70,
27314                baseline_comments: 0,
27315                current_comments: 0,
27316                comment_lines_delta: 0,
27317                blank_lines_delta: 0,
27318                total_lines_delta: 70,
27319                coverage_lines_hit_delta: None,
27320                coverage_line_pct_delta: None,
27321                baseline_coverage_line_pct: None,
27322                current_coverage_line_pct: None,
27323            },
27324            file_deltas: vec![
27325                sloc_core::FileDelta {
27326                    relative_path: "added.rs".to_string(),
27327                    language: Some("Rust".to_string()),
27328                    status: FileChangeStatus::Added,
27329                    baseline_code: 0,
27330                    current_code: 100,
27331                    code_delta: 100,
27332                    baseline_comment: 0,
27333                    current_comment: 0,
27334                    comment_delta: 0,
27335                    baseline_blank: 0,
27336                    current_blank: 0,
27337                    blank_delta: 0,
27338                    total_delta: 100,
27339                },
27340                sloc_core::FileDelta {
27341                    relative_path: "removed.rs".to_string(),
27342                    language: Some("Rust".to_string()),
27343                    status: FileChangeStatus::Removed,
27344                    baseline_code: 50,
27345                    current_code: 0,
27346                    code_delta: -50,
27347                    baseline_comment: 0,
27348                    current_comment: 0,
27349                    comment_delta: 0,
27350                    baseline_blank: 0,
27351                    current_blank: 0,
27352                    blank_delta: 0,
27353                    total_delta: -50,
27354                },
27355                sloc_core::FileDelta {
27356                    relative_path: "modified.rs".to_string(),
27357                    language: Some("Rust".to_string()),
27358                    status: FileChangeStatus::Modified,
27359                    baseline_code: 80,
27360                    current_code: 100,
27361                    code_delta: 20,
27362                    baseline_comment: 0,
27363                    current_comment: 0,
27364                    comment_delta: 0,
27365                    baseline_blank: 0,
27366                    current_blank: 0,
27367                    blank_delta: 0,
27368                    total_delta: 20,
27369                },
27370                sloc_core::FileDelta {
27371                    relative_path: "unchanged.rs".to_string(),
27372                    language: Some("Rust".to_string()),
27373                    status: FileChangeStatus::Unchanged,
27374                    baseline_code: 200,
27375                    current_code: 200,
27376                    code_delta: 0,
27377                    baseline_comment: 0,
27378                    current_comment: 0,
27379                    comment_delta: 0,
27380                    baseline_blank: 0,
27381                    current_blank: 0,
27382                    blank_delta: 0,
27383                    total_delta: 0,
27384                },
27385            ],
27386            files_added: 1,
27387            files_removed: 1,
27388            files_modified: 1,
27389            files_unchanged: 1,
27390        }
27391    }
27392
27393    #[test]
27394    fn sum_added_counts_added_and_positive_modified() {
27395        let cmp = make_mixed_scan_comparison();
27396        assert_eq!(sum_added_code_lines(&cmp), 120);
27397    }
27398
27399    #[test]
27400    fn sum_removed_counts_removed_baseline() {
27401        let cmp = make_mixed_scan_comparison();
27402        assert_eq!(sum_removed_code_lines(&cmp), 50);
27403    }
27404
27405    #[test]
27406    fn sum_unmodified_counts_unchanged_files() {
27407        let cmp = make_mixed_scan_comparison();
27408        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
27409    }
27410
27411    // ── detect_coverage_tool ──────────────────────────────────────────────────
27412
27413    #[test]
27414    fn detect_coverage_tool_rust_project() {
27415        let dir = tempfile::tempdir().unwrap();
27416        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
27417        let (tool, cmd) = detect_coverage_tool(dir.path());
27418        assert_eq!(tool, Some("cargo-llvm-cov"));
27419        assert!(cmd.is_some());
27420    }
27421
27422    #[test]
27423    fn detect_coverage_tool_java_gradle() {
27424        let dir = tempfile::tempdir().unwrap();
27425        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
27426        let (tool, _) = detect_coverage_tool(dir.path());
27427        assert_eq!(tool, Some("jacoco"));
27428    }
27429
27430    #[test]
27431    fn detect_coverage_tool_python_pyproject() {
27432        let dir = tempfile::tempdir().unwrap();
27433        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
27434        let (tool, _) = detect_coverage_tool(dir.path());
27435        assert_eq!(tool, Some("pytest-cov"));
27436    }
27437
27438    #[test]
27439    fn detect_coverage_tool_unknown_project() {
27440        let dir = tempfile::tempdir().unwrap();
27441        let (tool, cmd) = detect_coverage_tool(dir.path());
27442        assert!(tool.is_none() && cmd.is_none());
27443    }
27444
27445    // ── sanitize_path_str / display_path ─────────────────────────────────────
27446
27447    #[test]
27448    fn sanitize_path_str_unc_drive_stripped() {
27449        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
27450    }
27451
27452    #[test]
27453    fn sanitize_path_str_unc_network_stripped() {
27454        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
27455    }
27456
27457    #[test]
27458    fn sanitize_path_str_plain_path_unchanged() {
27459        assert_eq!(
27460            sanitize_path_str("/home/user/project"),
27461            "/home/user/project"
27462        );
27463    }
27464
27465    #[test]
27466    fn display_path_plain_linux_unchanged() {
27467        assert_eq!(
27468            display_path(Path::new("/home/user/project")),
27469            "/home/user/project"
27470        );
27471    }
27472
27473    #[test]
27474    fn display_path_unc_drive_stripped() {
27475        let result = display_path(Path::new(r"\\?\C:\Users\user"));
27476        assert_eq!(result, r"C:\Users\user");
27477    }
27478
27479    #[test]
27480    fn display_path_unc_network_stripped() {
27481        let result = display_path(Path::new(r"\\?\UNC\server\share"));
27482        assert_eq!(result, r"\\server\share");
27483    }
27484}